feat: P1 full implementation - 8 modules + frontend pages + feishu WS
P1 Backend (8 modules, 50+ new API endpoints): - P1-1: Stakeholder management (power-interest matrix) - P1-2: WBS task decomposition (tree structure + AI split) - P1-3: Risk management (register + matrix + AI identify) - P1-4: Requirement pool (MoSCoW + coverage + conflict detect) - P1-5: Change management (submit → evaluate → approve → implement) - P1-6: Health report (6 dimensions + red/yellow/green) - P1-7: Retrospective & knowledge base - P1-8: Multi-model support (6 models + strategy selection) Frontend (9 pages with sidebar navigation): - Project wizard, Kanban, Stakeholders, WBS, Risks, Requirements, Changes, Health, Retrospective Feishu integration: - WebSocket long-connection for receiving messages - Card button callback with decision tracking - 11 chat commands for P0+P1 features Tech: TypeScript, React 18, Arco Design, Hono, @larksuiteoapi/node-sdk
This commit is contained in:
27
P1-PLAN.md
Normal file
27
P1-PLAN.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# P1 开发计划
|
||||||
|
|
||||||
|
## Issue 拆分(按依赖顺序)
|
||||||
|
|
||||||
|
| # | Issue | 依赖 | 优先级 | 描述 |
|
||||||
|
|---|-------|------|--------|------|
|
||||||
|
| P1-1 | 干系人管理模块 | P0模型 | A | 权力-利益矩阵+参与策略推荐API |
|
||||||
|
| P1-2 | WBS任务拆解 | P0模型 | A | 树形结构+AI辅助拆解 |
|
||||||
|
| P1-3 | 风险管理 | P0模型 | A | 风险登记册+评估+应对策略 |
|
||||||
|
| P1-4 | 需求池管理 | P0模型 | A | MoSCoW+用户故事+冲突检测 |
|
||||||
|
| P1-5 | 变更管理 | P1-2 | B | 变更请求→评估→审批→执行 |
|
||||||
|
| P1-6 | 项目健康度报告 | P1-2,P1-3 | B | AI周报+红黄绿灯+趋势 |
|
||||||
|
| P1-7 | 复盘与知识沉淀 | P1-6 | C | 项目收尾复盘+知识归档 |
|
||||||
|
| P1-8 | 多模型支持 | 无 | B | 多模型路由+成本统计 |
|
||||||
|
|
||||||
|
## 执行策略
|
||||||
|
|
||||||
|
- P1-1/2/3/4 无互相依赖,可并行开发
|
||||||
|
- P1-5 依赖 WBS(P1-2),P1-6 依赖 WBS+风险,P1-7 依赖报告
|
||||||
|
- P1-8 独立,随时可做
|
||||||
|
|
||||||
|
## 技术规范
|
||||||
|
|
||||||
|
- 数据层:先用内存 Map/JSON(和 P0 一致),后续再迁 DB
|
||||||
|
- API:REST,Hono 路由,挂载到 main.ts
|
||||||
|
- 飞书通知:复用现有 feishu.ts 的发送能力
|
||||||
|
- 前端:暂不做,P1 先出后端 API + 飞书卡片交互
|
||||||
30
PROGRESS.md
30
PROGRESS.md
@@ -4,13 +4,15 @@
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
- **当前Issue:** 全部P0功能开发完成 ✅
|
- **当前Issue:** P1 全部完成 ✅
|
||||||
- **状态:** ✅ MVP内测版代码完成
|
- **状态:** ✅ P0+P1 后端 API 全部完成
|
||||||
- **上次更新:** 2026-04-11 19:05
|
- **上次更新:** 2026-04-12 11:15
|
||||||
- **PRD版本:** v0.2
|
- **PRD版本:** v0.2
|
||||||
- **Gitea仓库:** http://192.168.120.110:4000/xiaohei/pmp-tool
|
- **Gitea仓库:** http://192.168.120.110:4000/xiaohei/pmp-tool
|
||||||
|
|
||||||
## Issue流水线 — 全部完成
|
## Issue流水线
|
||||||
|
|
||||||
|
### P0 — 全部完成 ✅
|
||||||
|
|
||||||
| # | Issue | 文件 | 状态 |
|
| # | Issue | 文件 | 状态 |
|
||||||
|---|-------|------|------|
|
|---|-------|------|------|
|
||||||
@@ -29,6 +31,26 @@
|
|||||||
| 13 | P0-13: 内测反馈 | 集成在决策卡片+飞书通知中 | ✅ |
|
| 13 | P0-13: 内测反馈 | 集成在决策卡片+飞书通知中 | ✅ |
|
||||||
| 14 | P0-14: 打包部署脚本 | Dockerfile + docker-compose.yml | ✅ |
|
| 14 | P0-14: 打包部署脚本 | Dockerfile + docker-compose.yml | ✅ |
|
||||||
|
|
||||||
|
### P1 — 开发中 🔨
|
||||||
|
|
||||||
|
| # | Issue | 文件 | 状态 |
|
||||||
|
|---|-------|------|------|
|
||||||
|
| 15 | P1-1: 干系人管理 | src/lib/stakeholder.ts | ✅ |
|
||||||
|
| 16 | P1-2: WBS任务拆解 | src/lib/wbs.ts | ✅ |
|
||||||
|
| 17 | P1-3: 风险管理 | src/lib/risk.ts | ✅ |
|
||||||
|
| 18 | P1-4: 需求池管理 | src/lib/requirement.ts | ✅ |
|
||||||
|
| 19 | P1-5: 变更管理 | src/lib/change.ts | ✅ |
|
||||||
|
| 20 | P1-6: 项目健康度报告 | src/lib/health-report.ts | ✅ |
|
||||||
|
| 21 | P1-7: 复盘与知识沉淀 | src/lib/retrospective.ts | ✅ |
|
||||||
|
| 22 | P1-8: 多模型支持 | src/lib/multi-model.ts | ✅ |
|
||||||
|
|
||||||
|
### 基础设施更新
|
||||||
|
|
||||||
|
| # | 内容 | 状态 |
|
||||||
|
|---|------|------|
|
||||||
|
| - | 飞书长连接(WS)接收消息 | ✅ |
|
||||||
|
| - | 飞书卡片按钮回调 | ✅ |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,26 +1,83 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Layout, Menu } from '@arco-design/web-react';
|
||||||
|
import '@arco-design/web-react/dist/css/arco.css';
|
||||||
import WizardPage from './pages/WizardPage';
|
import WizardPage from './pages/WizardPage';
|
||||||
|
import KanbanPage from './pages/KanbanPage';
|
||||||
|
import StakeholderPage from './pages/StakeholderPage';
|
||||||
|
import WBSPage from './pages/WBSPage';
|
||||||
|
import RiskPage from './pages/RiskPage';
|
||||||
|
import RequirementPage from './pages/RequirementPage';
|
||||||
|
import ChangePage from './pages/ChangePage';
|
||||||
|
import HealthPage from './pages/HealthPage';
|
||||||
|
import RetrospectivePage from './pages/RetrospectivePage';
|
||||||
|
|
||||||
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
|
const MENU_ITEMS = [
|
||||||
|
{ key: 'wizard', label: '🚀 创建项目' },
|
||||||
|
{ key: 'kanban', label: '📋 看板' },
|
||||||
|
{ key: 'stakeholder', label: '👥 干系人' },
|
||||||
|
{ key: 'wbs', label: '🌳 WBS' },
|
||||||
|
{ key: 'risk', label: '⚠️ 风险' },
|
||||||
|
{ key: 'requirement', label: '📝 需求' },
|
||||||
|
{ key: 'change', label: '🔀 变更' },
|
||||||
|
{ key: 'health', label: '📊 健康度' },
|
||||||
|
{ key: 'retro', label: '🔄 复盘' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGES: Record<string, React.FC> = {
|
||||||
|
wizard: WizardPage,
|
||||||
|
kanban: KanbanPage,
|
||||||
|
stakeholder: StakeholderPage,
|
||||||
|
wbs: WBSPage,
|
||||||
|
risk: RiskPage,
|
||||||
|
requirement: RequirementPage,
|
||||||
|
change: ChangePage,
|
||||||
|
health: HealthPage,
|
||||||
|
retro: RetrospectivePage,
|
||||||
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [page, setPage] = useState('wizard');
|
||||||
|
const PageComponent = PAGES[page] || WizardPage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
<header style={{
|
<Layout.Header style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
borderBottom: '1px solid #e5e6eb',
|
borderBottom: '1px solid #e5e6eb',
|
||||||
padding: '12px 24px',
|
padding: '0 24px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
|
height: 48,
|
||||||
|
lineHeight: '48px',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ fontSize: 20 }}>⚡</span>
|
<span style={{ fontSize: 20 }}>⚡</span>
|
||||||
<span style={{ fontSize: 16, fontWeight: 600 }}>FlowPilot</span>
|
<span style={{ fontSize: 16, fontWeight: 600 }}>FlowPilot</span>
|
||||||
<span style={{ fontSize: 13, color: '#86909c' }}>流程领航 · AI驱动的项目管理</span>
|
<span style={{ fontSize: 13, color: '#86909c' }}>流程领航 · AI驱动的项目管理</span>
|
||||||
</header>
|
</Layout.Header>
|
||||||
<main>
|
<Layout>
|
||||||
<WizardPage />
|
<Sider
|
||||||
</main>
|
style={{ background: '#fff', borderRight: '1px solid #e5e6eb' }}
|
||||||
</div>
|
width={180}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
selectedKeys={[page]}
|
||||||
|
onClickMenuItem={(key) => setPage(key)}
|
||||||
|
style={{ borderRight: 'none', padding: '8px 0' }}
|
||||||
|
>
|
||||||
|
{MENU_ITEMS.map((item) => (
|
||||||
|
<Menu.Item key={item.key}>{item.label}</Menu.Item>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Sider>
|
||||||
|
<Content style={{ background: '#f7f8fa', padding: 24, overflow: 'auto' }}>
|
||||||
|
<PageComponent />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
162
src/lib/change.ts
Normal file
162
src/lib/change.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* 变更管理模块
|
||||||
|
* 变更请求 → AI评估影响 → 用户审批 → 自动执行
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ChangeType = 'scope' | 'schedule' | 'cost' | 'quality' | 'resource';
|
||||||
|
export type ChangeStatus = 'submitted' | 'evaluating' | 'pending_approval' | 'approved' | 'rejected' | 'implemented' | 'closed';
|
||||||
|
export type ChangeImpact = 'none' | 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export interface ChangeRequest {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: ChangeType;
|
||||||
|
status: ChangeStatus;
|
||||||
|
// 提交者
|
||||||
|
requester: string;
|
||||||
|
requesterOpenId?: string;
|
||||||
|
// 影响评估
|
||||||
|
impact: {
|
||||||
|
scope: ChangeImpact;
|
||||||
|
schedule: ChangeImpact;
|
||||||
|
cost: ChangeImpact;
|
||||||
|
quality: ChangeImpact;
|
||||||
|
overall: ChangeImpact;
|
||||||
|
description: string; // 影响说明
|
||||||
|
};
|
||||||
|
// 关联
|
||||||
|
relatedWBSNodes: string[];
|
||||||
|
relatedRisks: string[];
|
||||||
|
relatedRequirements: string[];
|
||||||
|
// 方案
|
||||||
|
proposedSolution: string;
|
||||||
|
estimatedEffort: number; // 预估工时
|
||||||
|
estimatedCost: number; // 预估费用
|
||||||
|
// 审批
|
||||||
|
approver?: string;
|
||||||
|
approvedAt?: string;
|
||||||
|
rejectionReason?: string;
|
||||||
|
// 执行
|
||||||
|
implementationNotes: string;
|
||||||
|
implementedAt?: string;
|
||||||
|
// 时间线
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, ChangeRequest>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 评估变更影响(模拟)
|
||||||
|
*/
|
||||||
|
export function evaluateChange(change: { title: string; description: string; type: ChangeType }): ChangeRequest['impact'] {
|
||||||
|
const typeImpactMap: Record<ChangeType, ChangeRequest['impact']> = {
|
||||||
|
scope: { scope: 'high', schedule: 'medium', cost: 'medium', quality: 'low', overall: 'high', description: '范围变更可能影响项目进度和成本,建议仔细评估后决定' },
|
||||||
|
schedule: { scope: 'low', schedule: 'high', cost: 'medium', quality: 'medium', overall: 'high', description: '进度变更可能影响交付质量和资源分配' },
|
||||||
|
cost: { scope: 'low', schedule: 'low', cost: 'high', quality: 'low', overall: 'medium', description: '成本变更需要评估是否影响项目范围和质量' },
|
||||||
|
quality: { scope: 'low', schedule: 'medium', cost: 'high', quality: 'high', overall: 'high', description: '质量变更可能需要额外资源和时间' },
|
||||||
|
resource: { scope: 'low', schedule: 'high', cost: 'medium', quality: 'medium', overall: 'medium', description: '资源变更可能影响项目进度和交付质量' },
|
||||||
|
};
|
||||||
|
return typeImpactMap[change.type] || typeImpactMap.scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建变更请求
|
||||||
|
*/
|
||||||
|
export function createChangeRequest(projectId: string, data: Omit<ChangeRequest, 'id' | 'projectId' | 'status' | 'impact' | 'createdAt' | 'updatedAt' | 'approver' | 'approvedAt' | 'rejectionReason' | 'implementedAt' | 'implementationNotes'>): ChangeRequest {
|
||||||
|
const id = `chg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const impact = evaluateChange({ title: data.title, description: data.description, type: data.type });
|
||||||
|
const cr: ChangeRequest = {
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
status: 'evaluating',
|
||||||
|
impact,
|
||||||
|
implementationNotes: '',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
// 自动转为待审批
|
||||||
|
cr.status = 'pending_approval';
|
||||||
|
store.set(id, cr);
|
||||||
|
return cr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectChanges(projectId: string, filters?: { type?: ChangeType; status?: ChangeStatus }): ChangeRequest[] {
|
||||||
|
let list = Array.from(store.values()).filter(c => c.projectId === projectId);
|
||||||
|
if (filters?.type) list = list.filter(c => c.type === filters.type);
|
||||||
|
if (filters?.status) list = list.filter(c => c.status === filters.status);
|
||||||
|
return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChange(id: string): ChangeRequest | undefined {
|
||||||
|
return store.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveChange(id: string, approver: string): ChangeRequest | null {
|
||||||
|
const cr = store.get(id);
|
||||||
|
if (!cr || cr.status !== 'pending_approval') return null;
|
||||||
|
cr.status = 'approved';
|
||||||
|
cr.approver = approver;
|
||||||
|
cr.approvedAt = new Date().toISOString();
|
||||||
|
cr.updatedAt = new Date().toISOString();
|
||||||
|
store.set(id, cr);
|
||||||
|
return cr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectChange(id: string, reason: string): ChangeRequest | null {
|
||||||
|
const cr = store.get(id);
|
||||||
|
if (!cr || cr.status !== 'pending_approval') return null;
|
||||||
|
cr.status = 'rejected';
|
||||||
|
cr.rejectionReason = reason;
|
||||||
|
cr.updatedAt = new Date().toISOString();
|
||||||
|
store.set(id, cr);
|
||||||
|
return cr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function implementChange(id: string, notes: string): ChangeRequest | null {
|
||||||
|
const cr = store.get(id);
|
||||||
|
if (!cr || cr.status !== 'approved') return null;
|
||||||
|
cr.status = 'implemented';
|
||||||
|
cr.implementationNotes = notes;
|
||||||
|
cr.implementedAt = new Date().toISOString();
|
||||||
|
cr.updatedAt = new Date().toISOString();
|
||||||
|
store.set(id, cr);
|
||||||
|
return cr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateChange(id: string, data: Partial<Omit<ChangeRequest, 'id' | 'projectId' | 'createdAt'>>): ChangeRequest | null {
|
||||||
|
const cr = store.get(id);
|
||||||
|
if (!cr) return null;
|
||||||
|
Object.assign(cr, data, { updatedAt: new Date().toISOString() });
|
||||||
|
store.set(id, cr);
|
||||||
|
return cr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteChange(id: string): boolean {
|
||||||
|
return store.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 变更统计
|
||||||
|
*/
|
||||||
|
export function getChangeStats(projectId: string): {
|
||||||
|
total: number;
|
||||||
|
byStatus: Record<ChangeStatus, number>;
|
||||||
|
byType: Record<ChangeType, number>;
|
||||||
|
approvalRate: number;
|
||||||
|
} {
|
||||||
|
const changes = Array.from(store.values()).filter(c => c.projectId === projectId);
|
||||||
|
const byStatus: Record<ChangeStatus, number> = { submitted: 0, evaluating: 0, pending_approval: 0, approved: 0, rejected: 0, implemented: 0, closed: 0 };
|
||||||
|
const byType: Record<ChangeType, number> = { scope: 0, schedule: 0, cost: 0, quality: 0, resource: 0 };
|
||||||
|
let approved = 0, total = changes.length;
|
||||||
|
for (const c of changes) {
|
||||||
|
byStatus[c.status]++;
|
||||||
|
byType[c.type]++;
|
||||||
|
if (c.status === 'approved' || c.status === 'implemented') approved++;
|
||||||
|
}
|
||||||
|
return { total, byStatus, byType, approvalRate: total > 0 ? approved / total : 0 };
|
||||||
|
}
|
||||||
142
src/lib/health-report.ts
Normal file
142
src/lib/health-report.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* 项目健康度报告模块
|
||||||
|
* AI 自动汇总生成周报 + 红黄绿灯 + 趋势分析
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type HealthStatus = 'green' | 'yellow' | 'red';
|
||||||
|
|
||||||
|
export interface HealthDimension {
|
||||||
|
name: string;
|
||||||
|
score: number; // 0-100
|
||||||
|
status: HealthStatus;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthReport {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
overall: HealthStatus;
|
||||||
|
overallScore: number; // 0-100
|
||||||
|
dimensions: HealthDimension[];
|
||||||
|
summary: string;
|
||||||
|
risks: string[];
|
||||||
|
recommendations: string[];
|
||||||
|
period: { from: string; to: string };
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, HealthReport>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算健康状态
|
||||||
|
*/
|
||||||
|
function scoreToStatus(score: number): HealthStatus {
|
||||||
|
if (score >= 70) return 'green';
|
||||||
|
if (score >= 40) return 'yellow';
|
||||||
|
return 'red';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成项目健康度报告
|
||||||
|
* 基于项目当前状态(任务、风险、需求等)模拟分析
|
||||||
|
*/
|
||||||
|
export function generateHealthReport(
|
||||||
|
projectId: string,
|
||||||
|
stats: {
|
||||||
|
totalTasks?: number;
|
||||||
|
completedTasks?: number;
|
||||||
|
inProgressTasks?: number;
|
||||||
|
blockedTasks?: number;
|
||||||
|
totalRisks?: number;
|
||||||
|
highRisks?: number;
|
||||||
|
totalRequirements?: number;
|
||||||
|
approvedRequirements?: number;
|
||||||
|
pendingChanges?: number;
|
||||||
|
overdueMilestones?: number;
|
||||||
|
},
|
||||||
|
period?: { from: string; to: string },
|
||||||
|
): HealthReport {
|
||||||
|
const now = new Date();
|
||||||
|
const from = period?.from || new Date(now.getTime() - 7 * 86400000).toISOString();
|
||||||
|
const to = period?.to || now.toISOString();
|
||||||
|
|
||||||
|
// 各维度评分
|
||||||
|
const taskCompletion = stats.totalTasks ? (stats.completedTasks || 0) / stats.totalTasks : 0.5;
|
||||||
|
const taskScore = Math.round(taskCompletion * 100);
|
||||||
|
|
||||||
|
const riskScore = stats.totalRisks
|
||||||
|
? Math.max(0, 100 - (stats.highRisks || 0) * 20)
|
||||||
|
: 80;
|
||||||
|
|
||||||
|
const reqScore = stats.totalRequirements
|
||||||
|
? Math.round(((stats.approvedRequirements || 0) / stats.totalRequirements) * 100)
|
||||||
|
: 70;
|
||||||
|
|
||||||
|
const blockedScore = stats.blockedTasks
|
||||||
|
? Math.max(0, 100 - stats.blockedTasks * 15)
|
||||||
|
: 90;
|
||||||
|
|
||||||
|
const changeScore = stats.pendingChanges
|
||||||
|
? Math.max(0, 100 - stats.pendingChanges * 10)
|
||||||
|
: 85;
|
||||||
|
|
||||||
|
const scheduleScore = stats.overdueMilestones
|
||||||
|
? Math.max(0, 100 - stats.overdueMilestones * 25)
|
||||||
|
: 85;
|
||||||
|
|
||||||
|
const dimensions: HealthDimension[] = [
|
||||||
|
{ name: '任务进度', score: taskScore, status: scoreToStatus(taskScore), trend: taskScore >= 50 ? 'up' : 'down', details: `完成率 ${taskScore}%,进行中 ${stats.inProgressTasks || 0} 个` },
|
||||||
|
{ name: '风险管理', score: riskScore, status: scoreToStatus(riskScore), trend: riskScore >= 70 ? 'stable' : 'down', details: `${stats.totalRisks || 0} 个风险,其中高危 ${stats.highRisks || 0} 个` },
|
||||||
|
{ name: '需求覆盖', score: reqScore, status: scoreToStatus(reqScore), trend: 'stable', details: `已审批 ${(stats.approvedRequirements || 0)}/${stats.totalRequirements || 0}` },
|
||||||
|
{ name: '阻塞状况', score: blockedScore, status: scoreToStatus(blockedScore), trend: blockedScore >= 70 ? 'up' : 'down', details: `${stats.blockedTasks || 0} 个阻塞任务` },
|
||||||
|
{ name: '变更控制', score: changeScore, status: scoreToStatus(changeScore), trend: 'stable', details: `${stats.pendingChanges || 0} 个待审批变更` },
|
||||||
|
{ name: '里程碑', score: scheduleScore, status: scoreToStatus(scheduleScore), trend: scheduleScore >= 70 ? 'up' : 'down', details: `${stats.overdueMilestones || 0} 个逾期里程碑` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const overallScore = Math.round(dimensions.reduce((s, d) => s + d.score, 0) / dimensions.length);
|
||||||
|
const overall = scoreToStatus(overallScore);
|
||||||
|
|
||||||
|
const statusEmoji = { green: '🟢', yellow: '🟡', red: '🔴' };
|
||||||
|
const summary = `项目健康度:${statusEmoji[overall]} ${overallScore}分(${overall === 'green' ? '健康' : overall === 'yellow' ? '需关注' : '风险'})`;
|
||||||
|
|
||||||
|
const risks: string[] = [];
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
for (const d of dimensions) {
|
||||||
|
if (d.status === 'red') {
|
||||||
|
risks.push(`🔴 ${d.name}:${d.details}`);
|
||||||
|
recommendations.push(`建议优先处理${d.name}相关问题`);
|
||||||
|
} else if (d.status === 'yellow') {
|
||||||
|
risks.push(`🟡 ${d.name}:${d.details}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (recommendations.length === 0) {
|
||||||
|
recommendations.push('项目状态良好,继续保持当前节奏');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `health-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const report: HealthReport = {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
overall,
|
||||||
|
overallScore,
|
||||||
|
dimensions,
|
||||||
|
summary,
|
||||||
|
risks,
|
||||||
|
recommendations,
|
||||||
|
period: { from, to },
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
store.set(id, report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectReports(projectId: string): HealthReport[] {
|
||||||
|
return Array.from(store.values())
|
||||||
|
.filter(r => r.projectId === projectId)
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReport(id: string): HealthReport | undefined {
|
||||||
|
return store.get(id);
|
||||||
|
}
|
||||||
307
src/lib/multi-model.ts
Normal file
307
src/lib/multi-model.ts
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* 多模型路由和成本追踪
|
||||||
|
* 支持多 AI 模型的选择、调用记录、成本统计和性能对比
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ModelProvider = 'openai' | 'anthropic' | 'zhipu' | 'qwen' | 'deepseek';
|
||||||
|
export type ModelCapability = 'document' | 'analysis' | 'code' | 'coordination' | 'creative';
|
||||||
|
export type SelectionStrategy = 'best_quality' | 'lowest_cost' | 'balanced' | 'fastest';
|
||||||
|
export type TaskType = ModelCapability;
|
||||||
|
|
||||||
|
export interface ModelConfig {
|
||||||
|
id: string;
|
||||||
|
provider: ModelProvider;
|
||||||
|
displayName: string;
|
||||||
|
capabilities: ModelCapability[];
|
||||||
|
maxTokens: number;
|
||||||
|
priceInput: number; // 元/千token
|
||||||
|
priceOutput: number; // 元/千token
|
||||||
|
avgLatencyMs: number;
|
||||||
|
avgScore: number; // 0-100
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelCallRecord {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
taskId?: string;
|
||||||
|
modelId: string;
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
cost: number;
|
||||||
|
latencyMs: number;
|
||||||
|
score?: number;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostStats {
|
||||||
|
totalCost: number;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCalls: number;
|
||||||
|
byModel: Record<string, { cost: number; tokens: number; calls: number; avgScore: number }>;
|
||||||
|
byDay: Record<string, { cost: number; tokens: number; calls: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelComparison {
|
||||||
|
modelId: string;
|
||||||
|
avgScore: number;
|
||||||
|
avgLatency: number;
|
||||||
|
totalCalls: number;
|
||||||
|
costPerCall: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 预注册模型
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const BUILTIN_MODELS: ModelConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'gpt-4o',
|
||||||
|
provider: 'openai',
|
||||||
|
displayName: 'GPT-4o',
|
||||||
|
capabilities: ['document', 'analysis', 'code', 'coordination', 'creative'],
|
||||||
|
maxTokens: 128000,
|
||||||
|
priceInput: 0.0175,
|
||||||
|
priceOutput: 0.07,
|
||||||
|
avgLatencyMs: 1800,
|
||||||
|
avgScore: 92,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-4o-mini',
|
||||||
|
provider: 'openai',
|
||||||
|
displayName: 'GPT-4o Mini',
|
||||||
|
capabilities: ['document', 'analysis', 'code', 'creative'],
|
||||||
|
maxTokens: 128000,
|
||||||
|
priceInput: 0.00105,
|
||||||
|
priceOutput: 0.0042,
|
||||||
|
avgLatencyMs: 800,
|
||||||
|
avgScore: 78,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-3.5-sonnet',
|
||||||
|
provider: 'anthropic',
|
||||||
|
displayName: 'Claude 3.5 Sonnet',
|
||||||
|
capabilities: ['document', 'analysis', 'code', 'coordination', 'creative'],
|
||||||
|
maxTokens: 200000,
|
||||||
|
priceInput: 0.021,
|
||||||
|
priceOutput: 0.105,
|
||||||
|
avgLatencyMs: 2000,
|
||||||
|
avgScore: 94,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'glm-4',
|
||||||
|
provider: 'zhipu',
|
||||||
|
displayName: 'GLM-4',
|
||||||
|
capabilities: ['document', 'analysis', 'coordination'],
|
||||||
|
maxTokens: 128000,
|
||||||
|
priceInput: 0.01,
|
||||||
|
priceOutput: 0.01,
|
||||||
|
avgLatencyMs: 1500,
|
||||||
|
avgScore: 80,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qwen-plus',
|
||||||
|
provider: 'qwen',
|
||||||
|
displayName: '通义千问 Plus',
|
||||||
|
capabilities: ['document', 'analysis', 'code', 'creative'],
|
||||||
|
maxTokens: 131072,
|
||||||
|
priceInput: 0.002,
|
||||||
|
priceOutput: 0.006,
|
||||||
|
avgLatencyMs: 1200,
|
||||||
|
avgScore: 82,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deepseek-chat',
|
||||||
|
provider: 'deepseek',
|
||||||
|
displayName: 'DeepSeek Chat',
|
||||||
|
capabilities: ['document', 'analysis', 'code'],
|
||||||
|
maxTokens: 64000,
|
||||||
|
priceInput: 0.001,
|
||||||
|
priceOutput: 0.002,
|
||||||
|
avgLatencyMs: 1000,
|
||||||
|
avgScore: 85,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 内存存储
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const modelStore = new Map<string, ModelConfig>();
|
||||||
|
const callRecords = new Map<string, ModelCallRecord>();
|
||||||
|
|
||||||
|
// 初始化内置模型
|
||||||
|
for (const model of BUILTIN_MODELS) {
|
||||||
|
modelStore.set(model.id, { ...model });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 核心函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function getModel(modelId: string): ModelConfig | undefined {
|
||||||
|
return modelStore.get(modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllModels(): ModelConfig[] {
|
||||||
|
return Array.from(modelStore.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateModel(modelId: string, patch: Partial<Pick<ModelConfig, 'enabled' | 'displayName' | 'avgScore' | 'avgLatencyMs'>>): ModelConfig | null {
|
||||||
|
const model = modelStore.get(modelId);
|
||||||
|
if (!model) return null;
|
||||||
|
Object.assign(model, patch);
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectModel(
|
||||||
|
taskType: TaskType,
|
||||||
|
strategy: SelectionStrategy = 'balanced',
|
||||||
|
): ModelConfig {
|
||||||
|
// 筛选:启用 + 能力匹配
|
||||||
|
const candidates = getAllModels().filter(m => m.enabled && m.capabilities.includes(taskType));
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error(`No enabled model found for task type: ${taskType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按策略排序
|
||||||
|
const sorted = [...candidates];
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case 'best_quality':
|
||||||
|
sorted.sort((a, b) => b.avgScore - a.avgScore);
|
||||||
|
break;
|
||||||
|
case 'lowest_cost':
|
||||||
|
sorted.sort((a, b) => (a.priceInput + a.priceOutput) - (b.priceInput + b.priceOutput));
|
||||||
|
break;
|
||||||
|
case 'fastest':
|
||||||
|
sorted.sort((a, b) => a.avgLatencyMs - b.avgLatencyMs);
|
||||||
|
break;
|
||||||
|
case 'balanced':
|
||||||
|
// 综合评分:质量权重 0.5,成本 0.3,速度 0.2
|
||||||
|
const maxCost = Math.max(...sorted.map(m => m.priceInput + m.priceOutput));
|
||||||
|
const maxLatency = Math.max(...sorted.map(m => m.avgLatencyMs));
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const scoreA = 0.5 * (a.avgScore / 100) - 0.3 * ((a.priceInput + a.priceOutput) / maxCost) - 0.2 * (a.avgLatencyMs / maxLatency);
|
||||||
|
const scoreB = 0.5 * (b.avgScore / 100) - 0.3 * ((b.priceInput + b.priceOutput) / maxCost) - 0.2 * (b.avgLatencyMs / maxLatency);
|
||||||
|
return scoreB - scoreA;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordCall(record: Omit<ModelCallRecord, 'id' | 'timestamp'>): ModelCallRecord {
|
||||||
|
const id = `call-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const entry: ModelCallRecord = {
|
||||||
|
...record,
|
||||||
|
id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
callRecords.set(id, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCostStats(
|
||||||
|
projectId: string,
|
||||||
|
period?: { from: string; to: string },
|
||||||
|
): CostStats {
|
||||||
|
const from = period?.from ? new Date(period.from) : new Date(0);
|
||||||
|
const to = period?.to ? new Date(period.to) : new Date();
|
||||||
|
|
||||||
|
let totalCost = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
let totalCalls = 0;
|
||||||
|
const byModel: Record<string, { cost: number; tokens: number; calls: number; scores: number[] }> = {};
|
||||||
|
const byDay: Record<string, { cost: number; tokens: number; calls: number }> = {};
|
||||||
|
|
||||||
|
for (const record of callRecords.values()) {
|
||||||
|
if (record.projectId !== projectId) continue;
|
||||||
|
const ts = new Date(record.timestamp);
|
||||||
|
if (ts < from || ts > to) continue;
|
||||||
|
|
||||||
|
totalCost += record.cost;
|
||||||
|
totalTokens += record.totalTokens;
|
||||||
|
totalCalls++;
|
||||||
|
|
||||||
|
// by model
|
||||||
|
if (!byModel[record.modelId]) {
|
||||||
|
byModel[record.modelId] = { cost: 0, tokens: 0, calls: 0, scores: [] };
|
||||||
|
}
|
||||||
|
byModel[record.modelId].cost += record.cost;
|
||||||
|
byModel[record.modelId].tokens += record.totalTokens;
|
||||||
|
byModel[record.modelId].calls++;
|
||||||
|
if (record.score !== undefined) {
|
||||||
|
byModel[record.modelId].scores.push(record.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// by day
|
||||||
|
const day = record.timestamp.slice(0, 10);
|
||||||
|
if (!byDay[day]) {
|
||||||
|
byDay[day] = { cost: 0, tokens: 0, calls: 0 };
|
||||||
|
}
|
||||||
|
byDay[day].cost += record.cost;
|
||||||
|
byDay[day].tokens += record.totalTokens;
|
||||||
|
byDay[day].calls++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 byModel,计算 avgScore
|
||||||
|
const byModelResult: CostStats['byModel'] = {};
|
||||||
|
for (const [modelId, data] of Object.entries(byModel)) {
|
||||||
|
byModelResult[modelId] = {
|
||||||
|
cost: Math.round(data.cost * 10000) / 10000,
|
||||||
|
tokens: data.tokens,
|
||||||
|
calls: data.calls,
|
||||||
|
avgScore: data.scores.length > 0
|
||||||
|
? Math.round(data.scores.reduce((a, b) => a + b, 0) / data.scores.length * 10) / 10
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCost: Math.round(totalCost * 10000) / 10000,
|
||||||
|
totalTokens,
|
||||||
|
totalCalls,
|
||||||
|
byModel: byModelResult,
|
||||||
|
byDay,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareModels(): ModelComparison[] {
|
||||||
|
const stats = new Map<string, { scores: number[]; latencies: number[]; costs: number[]; calls: number }>();
|
||||||
|
|
||||||
|
for (const record of callRecords.values()) {
|
||||||
|
if (!stats.has(record.modelId)) {
|
||||||
|
stats.set(record.modelId, { scores: [], latencies: [], costs: [], calls: 0 });
|
||||||
|
}
|
||||||
|
const s = stats.get(record.modelId)!;
|
||||||
|
s.calls++;
|
||||||
|
s.costs.push(record.cost);
|
||||||
|
s.latencies.push(record.latencyMs);
|
||||||
|
if (record.score !== undefined) s.scores.push(record.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ModelComparison[] = [];
|
||||||
|
for (const [modelId, s] of stats) {
|
||||||
|
result.push({
|
||||||
|
modelId,
|
||||||
|
avgScore: s.scores.length > 0 ? Math.round(s.scores.reduce((a, b) => a + b, 0) / s.scores.length * 10) / 10 : 0,
|
||||||
|
avgLatency: s.latencies.length > 0 ? Math.round(s.latencies.reduce((a, b) => a + b, 0) / s.latencies.length) : 0,
|
||||||
|
totalCalls: s.calls,
|
||||||
|
costPerCall: s.costs.length > 0 ? Math.round(s.costs.reduce((a, b) => a + b, 0) / s.costs.length * 10000) / 10000 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
279
src/lib/requirement.ts
Normal file
279
src/lib/requirement.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* 需求池管理模块
|
||||||
|
* MoSCoW优先级、冲突检测、覆盖率报告
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RequirementPriority = 'must' | 'should' | 'could' | 'wont';
|
||||||
|
export type RequirementCategory = 'functional' | 'non_functional' | 'constraint' | 'interface';
|
||||||
|
export type RequirementStatus = 'draft' | 'reviewed' | 'approved' | 'implemented' | 'verified' | 'deferred' | 'rejected';
|
||||||
|
|
||||||
|
export interface Requirement {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
priority: RequirementPriority;
|
||||||
|
userStory?: {
|
||||||
|
asA: string;
|
||||||
|
iWant: string;
|
||||||
|
soThat: string;
|
||||||
|
};
|
||||||
|
category: RequirementCategory;
|
||||||
|
status: RequirementStatus;
|
||||||
|
conflicts: string[];
|
||||||
|
dependencies: string[];
|
||||||
|
estimatedHours?: number;
|
||||||
|
complexity?: 'low' | 'medium' | 'high';
|
||||||
|
relatedTasks: string[];
|
||||||
|
relatedRisks: string[];
|
||||||
|
source: string;
|
||||||
|
requesterOpenId?: string;
|
||||||
|
tags: string[];
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOSCOW_ORDER: Record<RequirementPriority, number> = {
|
||||||
|
must: 0,
|
||||||
|
should: 1,
|
||||||
|
could: 2,
|
||||||
|
wont: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** In-memory store */
|
||||||
|
const requirementStore = new Map<string, Requirement>();
|
||||||
|
|
||||||
|
export function getStore(): Map<string, Requirement> {
|
||||||
|
return requirementStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStore(): void {
|
||||||
|
requirementStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Core Functions ----
|
||||||
|
|
||||||
|
/** MoSCoW优先级排序 */
|
||||||
|
export function sortByMoSCoW(requirements: Requirement[]): Requirement[] {
|
||||||
|
return [...requirements].sort((a, b) => MOSCOW_ORDER[a.priority] - MOSCOW_ORDER[b.priority]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI辅助需求分析(模拟) */
|
||||||
|
export function analyzeRequirement(
|
||||||
|
requirement: Requirement,
|
||||||
|
existingReqs: Requirement[] = [],
|
||||||
|
): {
|
||||||
|
suggestedPriority: RequirementPriority;
|
||||||
|
conflicts: string[];
|
||||||
|
missingInfo: string[];
|
||||||
|
suggestedCategory: RequirementCategory;
|
||||||
|
} {
|
||||||
|
const desc = requirement.description.toLowerCase();
|
||||||
|
const title = requirement.title.toLowerCase();
|
||||||
|
const combined = `${title} ${desc}`;
|
||||||
|
const missingInfo: string[] = [];
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
|
||||||
|
// Suggest priority based on keywords
|
||||||
|
let suggestedPriority: RequirementPriority = 'should';
|
||||||
|
if (combined.includes('必须') || combined.includes('核心') || combined.includes('关键') || combined.includes('essential') || combined.includes('critical')) {
|
||||||
|
suggestedPriority = 'must';
|
||||||
|
} else if (combined.includes('可选') || combined.includes('锦上添花') || combined.includes('nice to have') || combined.includes('optional')) {
|
||||||
|
suggestedPriority = 'could';
|
||||||
|
} else if (combined.includes('不做') || combined.includes('排除') || combined.includes('out of scope') || combined.includes('wont')) {
|
||||||
|
suggestedPriority = 'wont';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest category based on keywords
|
||||||
|
let suggestedCategory: RequirementCategory = 'functional';
|
||||||
|
if (combined.includes('性能') || combined.includes('安全') || combined.includes('可靠') || combined.includes('performance') || combined.includes('security')) {
|
||||||
|
suggestedCategory = 'non_functional';
|
||||||
|
} else if (combined.includes('约束') || combined.includes('限制') || combined.includes('constraint') || combined.includes('预算') || combined.includes('时间限制')) {
|
||||||
|
suggestedCategory = 'constraint';
|
||||||
|
} else if (combined.includes('接口') || combined.includes('集成') || combined.includes('api') || combined.includes('interface') || combined.includes('对接')) {
|
||||||
|
suggestedCategory = 'interface';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for missing info
|
||||||
|
if (desc.length < 20) missingInfo.push('描述过于简短,建议补充详细说明');
|
||||||
|
if (!requirement.source) missingInfo.push('未指定需求来源');
|
||||||
|
if (!requirement.estimatedHours) missingInfo.push('未提供工时估算');
|
||||||
|
if (requirement.dependencies.length === 0 && combined.includes('依赖')) missingInfo.push('提到依赖但未指定具体依赖项');
|
||||||
|
|
||||||
|
// Detect potential conflicts with existing requirements
|
||||||
|
for (const existing of existingReqs) {
|
||||||
|
if (existing.id === requirement.id) continue;
|
||||||
|
if (existing.projectId !== requirement.projectId) continue;
|
||||||
|
// Simple: same category + overlapping keywords
|
||||||
|
if (existing.category === requirement.category) {
|
||||||
|
const existingWords = new Set(existing.description.toLowerCase().split(/\s+/));
|
||||||
|
const reqWords = combined.split(/\s+/);
|
||||||
|
const overlap = reqWords.filter(w => w.length > 2 && existingWords.has(w)).length;
|
||||||
|
if (overlap >= 3) {
|
||||||
|
conflicts.push(existing.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestedPriority, conflicts, missingInfo, suggestedCategory };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需求冲突检测(模拟:同分类下描述相似度) */
|
||||||
|
export function detectConflicts(
|
||||||
|
reqs: Requirement[],
|
||||||
|
): Array<{ req1: string; req2: string; reason: string }> {
|
||||||
|
const result: Array<{ req1: string; req2: string; reason: string }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < reqs.length; i++) {
|
||||||
|
for (let j = i + 1; j < reqs.length; j++) {
|
||||||
|
const a = reqs[i];
|
||||||
|
const b = reqs[j];
|
||||||
|
if (a.projectId !== b.projectId) continue;
|
||||||
|
if (a.category !== b.category) continue;
|
||||||
|
|
||||||
|
const wordsA = new Set(a.description.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
||||||
|
const wordsB = new Set(b.description.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
||||||
|
const intersection = [...wordsA].filter(w => wordsB.has(w));
|
||||||
|
const union = new Set([...wordsA, ...wordsB]);
|
||||||
|
|
||||||
|
if (union.size > 0 && intersection.length / union.size > 0.3) {
|
||||||
|
result.push({
|
||||||
|
req1: a.id,
|
||||||
|
req2: b.id,
|
||||||
|
reason: `同分类"${a.category}"下描述相似度过高,重叠关键词: ${intersection.slice(0, 5).join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需求覆盖率报告 */
|
||||||
|
export function generateCoverageReport(requirements: Requirement[]): {
|
||||||
|
total: number;
|
||||||
|
byPriority: Record<RequirementPriority, number>;
|
||||||
|
byStatus: Record<RequirementStatus, number>;
|
||||||
|
byCategory: Record<RequirementCategory, number>;
|
||||||
|
coverageRate: number;
|
||||||
|
suggestions: string[];
|
||||||
|
} {
|
||||||
|
const priorities: RequirementPriority[] = ['must', 'should', 'could', 'wont'];
|
||||||
|
const statuses: RequirementStatus[] = ['draft', 'reviewed', 'approved', 'implemented', 'verified', 'deferred', 'rejected'];
|
||||||
|
const categories: RequirementCategory[] = ['functional', 'non_functional', 'constraint', 'interface'];
|
||||||
|
|
||||||
|
const byPriority = Object.fromEntries(priorities.map(p => [p, 0])) as Record<RequirementPriority, number>;
|
||||||
|
const byStatus = Object.fromEntries(statuses.map(s => [s, 0])) as Record<RequirementStatus, number>;
|
||||||
|
const byCategory = Object.fromEntries(categories.map(c => [c, 0])) as Record<RequirementCategory, number>;
|
||||||
|
|
||||||
|
const suggestions: string[] = [];
|
||||||
|
|
||||||
|
for (const req of requirements) {
|
||||||
|
byPriority[req.priority]++;
|
||||||
|
byStatus[req.status]++;
|
||||||
|
byCategory[req.category]++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const covered = byStatus['implemented'] + byStatus['verified'];
|
||||||
|
const coverageRate = requirements.length > 0 ? covered / requirements.length : 0;
|
||||||
|
|
||||||
|
// Suggestions
|
||||||
|
if (byPriority['must'] > 0 && byStatus['draft'] > 0) {
|
||||||
|
const draftMust = requirements.filter(r => r.priority === 'must' && r.status === 'draft').length;
|
||||||
|
if (draftMust > 0) suggestions.push(`有 ${draftMust} 个 Must 优先级需求仍处于草稿状态,建议尽快评审`);
|
||||||
|
}
|
||||||
|
if (byStatus['deferred'] > 0) {
|
||||||
|
suggestions.push(`有 ${byStatus['deferred']} 个需求被推迟,建议定期回顾`);
|
||||||
|
}
|
||||||
|
if (byPriority['must'] === 0 && requirements.length > 0) {
|
||||||
|
suggestions.push('没有 Must 优先级需求,建议明确核心需求');
|
||||||
|
}
|
||||||
|
if (coverageRate < 0.3 && requirements.length > 3) {
|
||||||
|
suggestions.push('覆盖率低于 30%,建议加快需求落地');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total: requirements.length, byPriority, byStatus, byCategory, coverageRate, suggestions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户故事生成辅助(模拟) */
|
||||||
|
export function generateUserStory(description: string): Requirement['userStory'] {
|
||||||
|
// Simple heuristic extraction
|
||||||
|
const desc = description.trim();
|
||||||
|
if (!desc) {
|
||||||
|
return { asA: '用户', iWant: '(请补充)', soThat: '(请补充)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract patterns like "作为...我想要...以便..."
|
||||||
|
const asAMatch = desc.match(/作为(.+?)[,,我]/);
|
||||||
|
const iWantMatch = desc.match(/(?:想要|希望|需要)(.+?)(?:以便|从而|这样可以|,|$)/);
|
||||||
|
const soThatMatch = desc.match(/(?:以便|从而|这样可以)(.+)$/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
asA: asAMatch ? asAMatch[1].trim() : '用户',
|
||||||
|
iWant: iWantMatch ? iWantMatch[1].trim() : desc.slice(0, 50),
|
||||||
|
soThat: soThatMatch ? soThatMatch[1].trim() : '(请补充目的)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a requirement */
|
||||||
|
export function createRequirement(
|
||||||
|
projectId: string,
|
||||||
|
data: Omit<Requirement, 'id' | 'projectId' | 'createdAt' | 'updatedAt' | 'conflicts' | 'dependencies' | 'relatedTasks' | 'relatedRisks' | 'tags' | 'notes'> & Partial<Pick<Requirement, 'conflicts' | 'dependencies' | 'relatedTasks' | 'relatedRisks' | 'tags' | 'notes'>>,
|
||||||
|
): Requirement {
|
||||||
|
const id = `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const req: Requirement = {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
conflicts: [],
|
||||||
|
dependencies: [],
|
||||||
|
relatedTasks: [],
|
||||||
|
relatedRisks: [],
|
||||||
|
tags: [],
|
||||||
|
notes: '',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
requirementStore.set(id, req);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get requirements by project with optional filters */
|
||||||
|
export function getRequirements(
|
||||||
|
projectId: string,
|
||||||
|
filters?: { priority?: RequirementPriority; status?: RequirementStatus; category?: RequirementCategory },
|
||||||
|
): Requirement[] {
|
||||||
|
let results = [...requirementStore.values()].filter(r => r.projectId === projectId);
|
||||||
|
if (filters?.priority) results = results.filter(r => r.priority === filters.priority);
|
||||||
|
if (filters?.status) results = results.filter(r => r.status === filters.status);
|
||||||
|
if (filters?.category) results = results.filter(r => r.category === filters.category);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get single requirement */
|
||||||
|
export function getRequirement(id: string): Requirement | undefined {
|
||||||
|
return requirementStore.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update requirement */
|
||||||
|
export function updateRequirement(id: string, updates: Partial<Requirement>): Requirement | null {
|
||||||
|
const existing = requirementStore.get(id);
|
||||||
|
if (!existing) return null;
|
||||||
|
const updated: Requirement = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
id: existing.id,
|
||||||
|
projectId: existing.projectId,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
requirementStore.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete requirement */
|
||||||
|
export function deleteRequirement(id: string): boolean {
|
||||||
|
return requirementStore.delete(id);
|
||||||
|
}
|
||||||
180
src/lib/retrospective.ts
Normal file
180
src/lib/retrospective.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 复盘与知识沉淀模块
|
||||||
|
* 项目收尾 AI 自动复盘,经验归入知识库
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type KnowledgeType = 'lesson' | 'best_practice' | 'template' | 'metric' | 'risk_pattern' | 'tool_tip';
|
||||||
|
|
||||||
|
export interface KnowledgeEntry {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
type: KnowledgeType;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tags: string[];
|
||||||
|
// 来源
|
||||||
|
source: 'ai_review' | 'user_input' | 'agent_log';
|
||||||
|
// 关联
|
||||||
|
relatedPhase?: string;
|
||||||
|
relatedTaskType?: string;
|
||||||
|
// 评分
|
||||||
|
usefulness: number; // 0-100,被引用/采纳的评分
|
||||||
|
references: number; // 被引用次数
|
||||||
|
// 元数据
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectRetrospective {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
// 做得好的
|
||||||
|
wentWell: string[];
|
||||||
|
// 待改进的
|
||||||
|
toImprove: string[];
|
||||||
|
// 经验教训
|
||||||
|
lessons: string[];
|
||||||
|
// 可复用的资产
|
||||||
|
reusableAssets: string[];
|
||||||
|
// 知识条目
|
||||||
|
knowledgeEntries: KnowledgeEntry[];
|
||||||
|
// AI 洞察
|
||||||
|
aiInsights: string[];
|
||||||
|
// 综合评分
|
||||||
|
projectScore: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knowledgeStore = new Map<string, KnowledgeEntry>();
|
||||||
|
const retroStore = new Map<string, ProjectRetrospective>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成项目复盘(模拟 AI 分析)
|
||||||
|
*/
|
||||||
|
export function generateRetrospective(
|
||||||
|
projectId: string,
|
||||||
|
projectData: {
|
||||||
|
name: string;
|
||||||
|
goal: string;
|
||||||
|
duration: string;
|
||||||
|
teamSize: number;
|
||||||
|
taskCompletionRate: number;
|
||||||
|
riskCount: number;
|
||||||
|
changeCount: number;
|
||||||
|
},
|
||||||
|
): ProjectRetrospective {
|
||||||
|
const { taskCompletionRate, riskCount, changeCount, teamSize } = projectData;
|
||||||
|
|
||||||
|
const wentWell: string[] = [];
|
||||||
|
const toImprove: string[] = [];
|
||||||
|
const lessons: string[] = [];
|
||||||
|
const reusableAssets: string[] = [];
|
||||||
|
|
||||||
|
// 基于数据生成复盘内容
|
||||||
|
if (taskCompletionRate >= 0.8) {
|
||||||
|
wentWell.push(`任务完成率 ${Math.round(taskCompletionRate * 100)}%,执行效率较高`);
|
||||||
|
lessons.push('明确的任务拆解和原子化分配有助于提高完成率');
|
||||||
|
} else {
|
||||||
|
toImprove.push(`任务完成率仅 ${Math.round(taskCompletionRate * 100)}%,需要优化任务拆解和分配策略`);
|
||||||
|
lessons.push('任务粒度过大或输入不明确会导致执行失败,应加强前期分析');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (riskCount <= 3) {
|
||||||
|
wentWell.push(`风险管理有效,仅识别 ${riskCount} 个风险`);
|
||||||
|
} else {
|
||||||
|
toImprove.push(`风险暴露较多(${riskCount} 个),前期风险识别需要加强`);
|
||||||
|
lessons.push('项目启动阶段应进行充分的风险识别,而非执行中被动应对');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changeCount <= 2) {
|
||||||
|
wentWell.push('变更控制良好,需求稳定性高');
|
||||||
|
reusableAssets.push('当前的需求管理流程可作为模板复用');
|
||||||
|
} else {
|
||||||
|
toImprove.push(`${changeCount} 次变更,需求变更管理需要改进`);
|
||||||
|
lessons.push('建立变更审批机制,控制非必要变更');
|
||||||
|
}
|
||||||
|
|
||||||
|
wentWell.push(`${teamSize} 人团队协作模式可复用`);
|
||||||
|
reusableAssets.push('项目管理流程文档可作为后续项目模板');
|
||||||
|
lessons.push('AI Agent 辅助执行在文档生成类任务中效率最高');
|
||||||
|
|
||||||
|
if (teamSize > 5) {
|
||||||
|
toImprove.push('大团队沟通成本高,建议引入更结构化的沟通机制');
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiInsights = [
|
||||||
|
`项目综合评分 ${Math.round(taskCompletionRate * 60 + (1 - riskCount / 20) * 20 + (1 - changeCount / 20) * 20)} 分`,
|
||||||
|
'AI Agent 在文档和分析任务中表现优于协调类任务',
|
||||||
|
'建议在后续项目中增加自动化检查点,减少人工确认环节',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 生成知识条目
|
||||||
|
const entries: KnowledgeEntry[] = lessons.map((lesson, i) => ({
|
||||||
|
id: `kb-${Date.now()}-${i}`,
|
||||||
|
projectId,
|
||||||
|
type: 'lesson' as KnowledgeType,
|
||||||
|
title: `经验教训 #${i + 1}`,
|
||||||
|
content: lesson,
|
||||||
|
tags: ['复盘', projectData.name],
|
||||||
|
source: 'ai_review' as const,
|
||||||
|
usefulness: 50 + Math.floor(Math.random() * 30),
|
||||||
|
references: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 保存知识条目
|
||||||
|
for (const e of entries) knowledgeStore.set(e.id, e);
|
||||||
|
|
||||||
|
const score = Math.round(taskCompletionRate * 60 + Math.max(0, 1 - riskCount / 20) * 20 + Math.max(0, 1 - changeCount / 20) * 20);
|
||||||
|
|
||||||
|
const retro: ProjectRetrospective = {
|
||||||
|
id: `retro-${Date.now()}`,
|
||||||
|
projectId,
|
||||||
|
wentWell,
|
||||||
|
toImprove,
|
||||||
|
lessons,
|
||||||
|
reusableAssets,
|
||||||
|
knowledgeEntries: entries,
|
||||||
|
aiInsights,
|
||||||
|
projectScore: Math.min(100, score),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
retroStore.set(retro.id, retro);
|
||||||
|
return retro;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 知识库 CRUD
|
||||||
|
export function addKnowledge(data: Omit<KnowledgeEntry, 'id' | 'usefulness' | 'references' | 'createdAt' | 'updatedAt'>): KnowledgeEntry {
|
||||||
|
const id = `kb-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const entry: KnowledgeEntry = { ...data, id, usefulness: 50, references: 0, createdAt: now, updatedAt: now };
|
||||||
|
knowledgeStore.set(id, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectKnowledge(projectId: string, type?: KnowledgeType): KnowledgeEntry[] {
|
||||||
|
let list = Array.from(knowledgeStore.values()).filter(e => e.projectId === projectId);
|
||||||
|
if (type) list = list.filter(e => e.type === type);
|
||||||
|
return list.sort((a, b) => b.usefulness - a.usefulness);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKnowledge(id: string): KnowledgeEntry | undefined {
|
||||||
|
return knowledgeStore.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchKnowledge(query: string, limit?: number): KnowledgeEntry[] {
|
||||||
|
const lower = query.toLowerCase();
|
||||||
|
const results = Array.from(knowledgeStore.values())
|
||||||
|
.filter(e => e.title.toLowerCase().includes(lower) || e.content.toLowerCase().includes(lower) || e.tags.some(t => t.toLowerCase().includes(lower)))
|
||||||
|
.sort((a, b) => b.usefulness - a.usefulness);
|
||||||
|
return limit ? results.slice(0, limit) : results;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectRetrospectives(projectId: string): ProjectRetrospective[] {
|
||||||
|
return Array.from(retroStore.values()).filter(r => r.projectId === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRetrospective(id: string): ProjectRetrospective | undefined {
|
||||||
|
return retroStore.get(id);
|
||||||
|
}
|
||||||
355
src/lib/risk.ts
Normal file
355
src/lib/risk.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* 风险管理模块 - 风险登记册和风险管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RiskCategory = 'technical' | 'schedule' | 'resource' | 'scope' | 'quality' | 'external' | 'management';
|
||||||
|
export type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
export type RiskStrategy = 'avoid' | 'transfer' | 'mitigate' | 'accept' | 'escalate';
|
||||||
|
export type RiskStatus = 'identified' | 'analyzing' | 'planned' | 'monitoring' | 'occurred' | 'resolved' | 'closed';
|
||||||
|
|
||||||
|
export interface Risk {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: RiskCategory;
|
||||||
|
probability: 1 | 2 | 3 | 4 | 5;
|
||||||
|
impact: 1 | 2 | 3 | 4 | 5;
|
||||||
|
riskScore: number;
|
||||||
|
level: RiskLevel;
|
||||||
|
strategy: RiskStrategy;
|
||||||
|
responsePlan: string;
|
||||||
|
triggerCondition: string;
|
||||||
|
owner: string;
|
||||||
|
status: RiskStatus;
|
||||||
|
dueDate?: string;
|
||||||
|
relatedTasks: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Risk level calculation ---
|
||||||
|
|
||||||
|
export function calculateRiskLevel(probability: number, impact: number): { score: number; level: RiskLevel } {
|
||||||
|
const score = probability * impact;
|
||||||
|
let level: RiskLevel;
|
||||||
|
if (score >= 17) level = 'critical';
|
||||||
|
else if (score >= 10) level = 'high';
|
||||||
|
else if (score >= 5) level = 'medium';
|
||||||
|
else level = 'low';
|
||||||
|
return { score, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Probability-Impact 5x5 matrix ---
|
||||||
|
|
||||||
|
export function getProbabilityImpactMatrix(): number[][] {
|
||||||
|
return [
|
||||||
|
[1, 2, 3, 4, 5],
|
||||||
|
[2, 4, 6, 8, 10],
|
||||||
|
[3, 6, 9, 12, 15],
|
||||||
|
[4, 8, 12, 16, 20],
|
||||||
|
[5, 10, 15, 20, 25],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AI risk identification (simulated) ---
|
||||||
|
|
||||||
|
export function identifyRisks(projectDescription: string, projectType?: string): Omit<Risk, 'id' | 'projectId' | 'createdAt' | 'updatedAt'>[] {
|
||||||
|
const desc = projectDescription.toLowerCase();
|
||||||
|
const risks: Omit<Risk, 'id' | 'projectId' | 'createdAt' | 'updatedAt'>[] = [];
|
||||||
|
|
||||||
|
const templates: Array<{
|
||||||
|
keywords: string[];
|
||||||
|
risk: Omit<Risk, 'id' | 'projectId' | 'createdAt' | 'updatedAt'>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
keywords: ['技术', 'tech', 'api', '系统', '架构', '平台'],
|
||||||
|
risk: {
|
||||||
|
title: '技术方案不成熟',
|
||||||
|
description: '项目涉及的技术栈或架构方案缺乏验证,可能导致开发延期或质量不达标',
|
||||||
|
category: 'technical',
|
||||||
|
probability: 3,
|
||||||
|
impact: 4,
|
||||||
|
riskScore: 12,
|
||||||
|
level: 'high',
|
||||||
|
strategy: 'mitigate',
|
||||||
|
responsePlan: '在项目启动阶段进行技术预研和原型验证',
|
||||||
|
triggerCondition: '技术预研发现关键组件无法满足需求',
|
||||||
|
owner: '技术负责人',
|
||||||
|
status: 'identified',
|
||||||
|
relatedTasks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['时间', 'deadline', '排期', '紧急', 'urgent'],
|
||||||
|
risk: {
|
||||||
|
title: '项目进度延误',
|
||||||
|
description: '项目排期紧张,关键路径上的任务延期可能导致整体交付推迟',
|
||||||
|
category: 'schedule',
|
||||||
|
probability: 4,
|
||||||
|
impact: 4,
|
||||||
|
riskScore: 16,
|
||||||
|
level: 'high',
|
||||||
|
strategy: 'mitigate',
|
||||||
|
responsePlan: '设置里程碑检查点,预留缓冲时间,关键路径任务优先',
|
||||||
|
triggerCondition: '关键任务延期超过计划20%',
|
||||||
|
owner: '项目经理',
|
||||||
|
status: 'identified',
|
||||||
|
relatedTasks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['人员', '团队', 'resource', '外包', '供应商'],
|
||||||
|
risk: {
|
||||||
|
title: '关键人员流失或不足',
|
||||||
|
description: '核心成员离职或人手不足导致项目推进困难',
|
||||||
|
category: 'resource',
|
||||||
|
probability: 2,
|
||||||
|
impact: 5,
|
||||||
|
riskScore: 10,
|
||||||
|
level: 'high',
|
||||||
|
strategy: 'mitigate',
|
||||||
|
responsePlan: '建立知识备份机制,交叉培训,提前储备人才',
|
||||||
|
triggerCondition: '核心成员提出离职或请假超过2周',
|
||||||
|
owner: '项目经理',
|
||||||
|
status: 'identified',
|
||||||
|
relatedTasks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keywords: ['需求', 'requirement', '变更', 'scope'],
|
||||||
|
risk: {
|
||||||
|
title: '需求范围蔓延',
|
||||||
|
description: '项目执行过程中需求不断膨胀,超出原始范围定义',
|
||||||
|
category: 'scope',
|
||||||
|
probability: 4,
|
||||||
|
impact: 3,
|
||||||
|
riskScore: 12,
|
||||||
|
level: 'high',
|
||||||
|
strategy: 'avoid',
|
||||||
|
responsePlan: '严格执行变更控制流程,所有需求变更需评估影响后审批',
|
||||||
|
triggerCondition: '单个迭代新增需求超过3个',
|
||||||
|
owner: '产品经理',
|
||||||
|
status: 'identified',
|
||||||
|
relatedTasks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default risks if no keywords match
|
||||||
|
const defaultRisks: Omit<Risk, 'id' | 'projectId' | 'createdAt' | 'updatedAt'>[] = [
|
||||||
|
{
|
||||||
|
title: '技术实现风险',
|
||||||
|
description: '核心技术方案可能存在未预见的实现难度',
|
||||||
|
category: 'technical',
|
||||||
|
probability: 3,
|
||||||
|
impact: 3,
|
||||||
|
riskScore: 9,
|
||||||
|
level: 'medium',
|
||||||
|
strategy: 'mitigate',
|
||||||
|
responsePlan: '提前进行技术验证,准备备选方案',
|
||||||
|
triggerCondition: '技术验证阶段发现重大阻碍',
|
||||||
|
owner: '技术负责人',
|
||||||
|
status: 'identified',
|
||||||
|
relatedTasks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '进度风险',
|
||||||
|
description: '项目可能因各种因素导致交付延期',
|
||||||
|
category: 'schedule',
|
||||||
|
probability: 3,
|
||||||
|
impact: 4,
|
||||||
|
riskScore: 12,
|
||||||
|
level: 'high',
|
||||||
|
strategy: 'mitigate',
|
||||||
|
responsePlan: '设置里程碑检查点,预留缓冲时间',
|
||||||
|
triggerCondition: '关键路径任务延期超过15%',
|
||||||
|
owner: '项目经理',
|
||||||
|
status: 'identified',
|
||||||
|
relatedTasks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '资源不足风险',
|
||||||
|
description: '人力或预算可能不足以支撑项目完成',
|
||||||
|
category: 'resource',
|
||||||
|
probability: 2,
|
||||||
|
impact: 4,
|
||||||
|
riskScore: 8,
|
||||||
|
level: 'medium',
|
||||||
|
strategy: 'mitigate',
|
||||||
|
responsePlan: '提前锁定关键资源,制定资源替代方案',
|
||||||
|
triggerCondition: '资源利用率超过90%',
|
||||||
|
owner: '项目经理',
|
||||||
|
status: 'identified',
|
||||||
|
relatedTasks: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Match templates by keywords
|
||||||
|
for (const t of templates) {
|
||||||
|
if (t.keywords.some(k => desc.includes(k))) {
|
||||||
|
risks.push(t.risk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always include at least 3 risks, fill from defaults
|
||||||
|
if (risks.length < 3) {
|
||||||
|
for (const d of defaultRisks) {
|
||||||
|
if (!risks.some(r => r.title === d.title)) {
|
||||||
|
risks.push(d);
|
||||||
|
}
|
||||||
|
if (risks.length >= 3) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return risks.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Recommend response strategy ---
|
||||||
|
|
||||||
|
export function recommendResponse(risk: Risk): { strategy: RiskStrategy; plan: string; trigger: string } {
|
||||||
|
const strategies: Record<RiskCategory, Record<RiskLevel, { strategy: RiskStrategy; plan: string; trigger: string }>> = {
|
||||||
|
technical: {
|
||||||
|
critical: { strategy: 'avoid', plan: '更换技术方案,使用成熟替代方案', trigger: '技术验证连续2次失败' },
|
||||||
|
high: { strategy: 'mitigate', plan: '安排技术预研,准备降级方案', trigger: '原型验证发现关键缺陷' },
|
||||||
|
medium: { strategy: 'mitigate', plan: '增加代码审查和测试覆盖率', trigger: '缺陷率超过阈值' },
|
||||||
|
low: { strategy: 'accept', plan: '持续监控,定期评估', trigger: '风险指标异常' },
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
critical: { strategy: 'escalate', plan: '上报管理层,申请额外资源或调整交付范围', trigger: '关键路径延期超过30%' },
|
||||||
|
high: { strategy: 'mitigate', plan: '压缩非关键路径,增加并行任务,加班赶工', trigger: '里程碑延期超过1周' },
|
||||||
|
medium: { strategy: 'mitigate', plan: '优化任务依赖,减少等待时间', trigger: '进度偏差超过15%' },
|
||||||
|
low: { strategy: 'accept', plan: '保持当前节奏,持续跟踪', trigger: '进度偏差超过10%' },
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
critical: { strategy: 'escalate', plan: '紧急调配资源,考虑外包或外部招聘', trigger: '关键岗位空缺超过2周' },
|
||||||
|
high: { strategy: 'transfer', plan: '将部分工作外包,或从其他项目借调人员', trigger: '资源缺口影响关键任务' },
|
||||||
|
medium: { strategy: 'mitigate', plan: '交叉培训,建立知识备份', trigger: '资源利用率持续超过85%' },
|
||||||
|
low: { strategy: 'accept', plan: '保持现有资源配置', trigger: '资源波动' },
|
||||||
|
},
|
||||||
|
scope: {
|
||||||
|
critical: { strategy: 'avoid', plan: '冻结需求,严格执行变更控制', trigger: '需求变更频率超过每周3次' },
|
||||||
|
high: { strategy: 'mitigate', plan: '建立需求变更审批流程,评估影响后决策', trigger: '范围变更累计超过15%' },
|
||||||
|
medium: { strategy: 'mitigate', plan: '定期与干系人确认需求优先级', trigger: '干系人提出新需求' },
|
||||||
|
low: { strategy: 'accept', plan: '接受小范围调整', trigger: '非核心需求变更' },
|
||||||
|
},
|
||||||
|
quality: {
|
||||||
|
critical: { strategy: 'avoid', plan: '暂停开发,优先修复质量问题', trigger: '测试通过率低于60%' },
|
||||||
|
high: { strategy: 'mitigate', plan: '增加测试资源,强化质量把关', trigger: '缺陷密度超过行业标准' },
|
||||||
|
medium: { strategy: 'mitigate', plan: '加强代码审查和自动化测试', trigger: '质量指标下降趋势' },
|
||||||
|
low: { strategy: 'accept', plan: '保持当前质量标准', trigger: '偶发质量问题' },
|
||||||
|
},
|
||||||
|
external: {
|
||||||
|
critical: { strategy: 'transfer', plan: '购买保险或签订保障协议,转移风险', trigger: '外部环境重大变化' },
|
||||||
|
high: { strategy: 'mitigate', plan: '制定应急预案,建立多方沟通机制', trigger: '外部依赖方出现异常' },
|
||||||
|
medium: { strategy: 'mitigate', plan: '多供应商策略,减少单点依赖', trigger: '供应商响应延迟' },
|
||||||
|
low: { strategy: 'accept', plan: '保持监控', trigger: '外部因素微小波动' },
|
||||||
|
},
|
||||||
|
management: {
|
||||||
|
critical: { strategy: 'escalate', plan: '上报高层管理者,重新评估项目治理结构', trigger: '决策延迟超过2周' },
|
||||||
|
high: { strategy: 'mitigate', plan: '优化决策流程,设立项目指导委员会', trigger: '关键决策未按时做出' },
|
||||||
|
medium: { strategy: 'mitigate', plan: '加强沟通频率,明确责任分工', trigger: '跨部门协调不畅' },
|
||||||
|
low: { strategy: 'accept', plan: '保持现有管理模式', trigger: '管理效率微降' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return strategies[risk.category]?.[risk.level] ?? { strategy: 'accept', plan: '持续监控', trigger: '风险状态变化' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Risk matrix report ---
|
||||||
|
|
||||||
|
export interface RiskMatrixReport {
|
||||||
|
matrix: Record<RiskLevel, Risk[]>;
|
||||||
|
topRisks: Risk[];
|
||||||
|
summary: string;
|
||||||
|
trendInsight: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRiskMatrix(risks: Risk[]): RiskMatrixReport {
|
||||||
|
const matrix: Record<RiskLevel, Risk[]> = { low: [], medium: [], high: [], critical: [] };
|
||||||
|
for (const r of risks) {
|
||||||
|
matrix[r.level].push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topRisks = [...risks].sort((a, b) => b.riskScore - a.riskScore).slice(0, 5);
|
||||||
|
|
||||||
|
const total = risks.length;
|
||||||
|
const critical = matrix.critical.length;
|
||||||
|
const high = matrix.high.length;
|
||||||
|
const medium = matrix.medium.length;
|
||||||
|
const low = matrix.low.length;
|
||||||
|
const occurred = risks.filter(r => r.status === 'occurred').length;
|
||||||
|
|
||||||
|
let summary: string;
|
||||||
|
if (critical > 0) {
|
||||||
|
summary = `⚠️ 项目存在 ${critical} 个严重风险,需要立即处理。共 ${total} 个风险(严重${critical}/高${high}/中${medium}/低${low}),${occurred} 个已发生。`;
|
||||||
|
} else if (high > 0) {
|
||||||
|
summary = `📊 项目风险可控。共 ${total} 个风险(高${high}/中${medium}/低${low}),重点关注 ${high} 个高风险项。`;
|
||||||
|
} else {
|
||||||
|
summary = `✅ 项目风险较低。共 ${total} 个风险(中${medium}/低${low}),整体风险可控。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insights = [
|
||||||
|
'风险分布趋于稳定,建议继续定期评估。',
|
||||||
|
'近期新增风险较多,建议加强风险识别频率。',
|
||||||
|
'高风险项有所减少,应对措施效果良好。',
|
||||||
|
'建议关注外部环境变化对项目的影响。',
|
||||||
|
];
|
||||||
|
const trendInsight = insights[Math.floor(Date.now() / 86400000) % insights.length];
|
||||||
|
|
||||||
|
return { matrix, topRisks, summary, trendInsight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- In-memory store ---
|
||||||
|
|
||||||
|
const riskStore = new Map<string, Risk>();
|
||||||
|
|
||||||
|
let riskIdCounter = 0;
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `risk-${++riskIdCounter}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRisk(projectId: string, data: Omit<Risk, 'id' | 'projectId' | 'riskScore' | 'level' | 'createdAt' | 'updatedAt'>): Risk {
|
||||||
|
const { score, level } = calculateRiskLevel(data.probability, data.impact);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const risk: Risk = {
|
||||||
|
...data,
|
||||||
|
id: generateId(),
|
||||||
|
projectId,
|
||||||
|
riskScore: score,
|
||||||
|
level,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
riskStore.set(risk.id, risk);
|
||||||
|
return risk;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRisk(id: string): Risk | undefined {
|
||||||
|
return riskStore.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRisksByProject(projectId: string, filters?: { level?: RiskLevel; status?: RiskStatus }): Risk[] {
|
||||||
|
let risks = Array.from(riskStore.values()).filter(r => r.projectId === projectId);
|
||||||
|
if (filters?.level) risks = risks.filter(r => r.level === filters.level);
|
||||||
|
if (filters?.status) risks = risks.filter(r => r.status === filters.status);
|
||||||
|
return risks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRisk(id: string, updates: Partial<Omit<Risk, 'id' | 'projectId' | 'createdAt'>>): Risk | undefined {
|
||||||
|
const risk = riskStore.get(id);
|
||||||
|
if (!risk) return undefined;
|
||||||
|
const merged = { ...risk, ...updates, updatedAt: new Date().toISOString() };
|
||||||
|
if (updates.probability !== undefined || updates.impact !== undefined) {
|
||||||
|
const { score, level } = calculateRiskLevel(merged.probability, merged.impact);
|
||||||
|
merged.riskScore = score;
|
||||||
|
merged.level = level;
|
||||||
|
}
|
||||||
|
riskStore.set(id, merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRisk(id: string): boolean {
|
||||||
|
return riskStore.delete(id);
|
||||||
|
}
|
||||||
154
src/lib/stakeholder.ts
Normal file
154
src/lib/stakeholder.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* 干系人管理模块
|
||||||
|
* 权力-利益矩阵 + 参与策略推荐
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StakeholderCategory = 'manage_closely' | 'keep_satisfied' | 'keep_informed' | 'monitor';
|
||||||
|
|
||||||
|
export interface Stakeholder {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
organization: string;
|
||||||
|
contact: string;
|
||||||
|
power: 1 | 2 | 3 | 4 | 5;
|
||||||
|
interest: 1 | 2 | 3 | 4 | 5;
|
||||||
|
category: StakeholderCategory;
|
||||||
|
engagementStrategy: string;
|
||||||
|
communicationMethod: string;
|
||||||
|
communicationFreq: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, Stakeholder>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权力-利益四象限分类
|
||||||
|
*/
|
||||||
|
export function classifyStakeholder(power: number, interest: number): StakeholderCategory {
|
||||||
|
const pHigh = power >= 3;
|
||||||
|
const iHigh = interest >= 3;
|
||||||
|
if (pHigh && iHigh) return 'manage_closely';
|
||||||
|
if (pHigh && !iHigh) return 'keep_satisfied';
|
||||||
|
if (!pHigh && iHigh) return 'keep_informed';
|
||||||
|
return 'monitor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI推荐参与策略(模拟)
|
||||||
|
*/
|
||||||
|
export function recommendStrategy(
|
||||||
|
category: StakeholderCategory,
|
||||||
|
role: string,
|
||||||
|
): { strategy: string; method: string; frequency: string } {
|
||||||
|
const strategies: Record<StakeholderCategory, { strategy: string; method: string; frequency: string }> = {
|
||||||
|
manage_closely: {
|
||||||
|
strategy: `密切管理「${role}」,确保其需求得到满足,主动寻求其意见和反馈`,
|
||||||
|
method: '面对面会议 / 视频会议 / 专项沟通',
|
||||||
|
frequency: '每周至少一次',
|
||||||
|
},
|
||||||
|
keep_satisfied: {
|
||||||
|
strategy: `保持「${role}」满意,定期汇报进展,避免意外`,
|
||||||
|
method: '正式报告 / 关键节点通知',
|
||||||
|
frequency: '每两周一次',
|
||||||
|
},
|
||||||
|
keep_informed: {
|
||||||
|
strategy: `保持「${role}」知情,通过常规渠道更新信息`,
|
||||||
|
method: '邮件 / 飞书群消息 / 周报',
|
||||||
|
frequency: '每月一次',
|
||||||
|
},
|
||||||
|
monitor: {
|
||||||
|
strategy: `监控「${role}」的态度变化,必要时调整策略`,
|
||||||
|
method: '公告 / 项目文档',
|
||||||
|
frequency: '按需',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return strategies[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 干系人分析报告
|
||||||
|
*/
|
||||||
|
export function generateStakeholderAnalysis(stakeholders: Stakeholder[]) {
|
||||||
|
const matrix: Record<StakeholderCategory, Stakeholder[]> = {
|
||||||
|
manage_closely: [],
|
||||||
|
keep_satisfied: [],
|
||||||
|
keep_informed: [],
|
||||||
|
monitor: [],
|
||||||
|
};
|
||||||
|
for (const s of stakeholders) {
|
||||||
|
matrix[s.category].push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = stakeholders.length;
|
||||||
|
const summary = `共 ${total} 位干系人:密切管理 ${matrix.manage_closely.length} 人,保持满意 ${matrix.keep_satisfied.length} 人,保持知情 ${matrix.keep_informed.length} 人,监督 ${matrix.monitor.length} 人`;
|
||||||
|
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
if (matrix.manage_closely.length > 5) {
|
||||||
|
recommendations.push('⚠️ 密切管理干系人超过5位,建议重点关注前5位高影响力人员');
|
||||||
|
}
|
||||||
|
if (matrix.keep_satisfied.length === 0 && total > 3) {
|
||||||
|
recommendations.push('💡 没有识别出"保持满意"类干系人,检查是否遗漏了高权力低利益的关键方');
|
||||||
|
}
|
||||||
|
if (matrix.monitor.length > total * 0.5) {
|
||||||
|
recommendations.push('📊 超半数干系人在监督象限,考虑是否需要更多互动');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { matrix, summary, recommendations };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建干系人
|
||||||
|
*/
|
||||||
|
export function createStakeholder(data: Omit<Stakeholder, 'id' | 'category' | 'engagementStrategy' | 'communicationMethod' | 'communicationFreq' | 'createdAt' | 'updatedAt'>): Stakeholder {
|
||||||
|
const category = classifyStakeholder(data.power, data.interest);
|
||||||
|
const strategy = recommendStrategy(category, data.role);
|
||||||
|
const id = `sh-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const s: Stakeholder = {
|
||||||
|
...data,
|
||||||
|
id,
|
||||||
|
category,
|
||||||
|
engagementStrategy: strategy.strategy,
|
||||||
|
communicationMethod: strategy.method,
|
||||||
|
communicationFreq: strategy.frequency,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
store.set(id, s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取项目的干系人列表
|
||||||
|
*/
|
||||||
|
export function getProjectStakeholders(projectId: string): Stakeholder[] {
|
||||||
|
return Array.from(store.values()).filter(s => s.projectId === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStakeholder(id: string): Stakeholder | undefined {
|
||||||
|
return store.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStakeholder(id: string, data: Partial<Omit<Stakeholder, 'id' | 'projectId' | 'createdAt'>>): Stakeholder | null {
|
||||||
|
const s = store.get(id);
|
||||||
|
if (!s) return null;
|
||||||
|
const updated = { ...s, ...data, updatedAt: new Date().toISOString() };
|
||||||
|
// 重新分类
|
||||||
|
if (data.power !== undefined || data.interest !== undefined) {
|
||||||
|
updated.category = classifyStakeholder(updated.power, updated.interest);
|
||||||
|
const strategy = recommendStrategy(updated.category, updated.role);
|
||||||
|
updated.engagementStrategy = strategy.strategy;
|
||||||
|
updated.communicationMethod = strategy.method;
|
||||||
|
updated.communicationFreq = strategy.frequency;
|
||||||
|
}
|
||||||
|
store.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteStakeholder(id: string): boolean {
|
||||||
|
return store.delete(id);
|
||||||
|
}
|
||||||
213
src/lib/wbs.ts
Normal file
213
src/lib/wbs.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* WBS(Work Breakdown Structure)任务拆解模块
|
||||||
|
* 树形结构 + AI辅助拆解
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WBSNode {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
level: number; // 0=项目, 1=阶段, 2=工作包, 3=活动
|
||||||
|
wbsCode: string; // 如 "1.2.3"
|
||||||
|
estimatedHours: number;
|
||||||
|
assignee: string;
|
||||||
|
assigneeType: 'human' | 'ai' | 'mixed';
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed' | 'blocked';
|
||||||
|
priority: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
dependencies: string[];
|
||||||
|
deliverables: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, WBSNode>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 WBS 编号
|
||||||
|
*/
|
||||||
|
export function generateWBSCode(parentCode: string, childIndex: number): string {
|
||||||
|
if (!parentCode) return String(childIndex);
|
||||||
|
return `${parentCode}.${childIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建树形结构(带 children 字段)
|
||||||
|
*/
|
||||||
|
export function buildWBSTree(nodes: WBSNode[]): Array<WBSNode & { children: WBSNode[] }> {
|
||||||
|
type TreeNode = WBSNode & { children: WBSNode[] };
|
||||||
|
const map = new Map<string, TreeNode>();
|
||||||
|
const roots: TreeNode[] = [];
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
map.set(n.id, { ...n, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
const node = map.get(n.id)!;
|
||||||
|
if (n.parentId && map.has(n.parentId)) {
|
||||||
|
map.get(n.parentId)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortNodes = (arr: TreeNode[]) => {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const ca = a.wbsCode.split('.').map(Number);
|
||||||
|
const cb = b.wbsCode.split('.').map(Number);
|
||||||
|
for (let i = 0; i < Math.max(ca.length, cb.length); i++) {
|
||||||
|
const diff = (ca[i] || 0) - (cb[i] || 0);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
for (const n of arr) sortNodes(n.children as TreeNode[]);
|
||||||
|
};
|
||||||
|
sortNodes(roots);
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算总工时(递归)
|
||||||
|
*/
|
||||||
|
export function calculateTotalHours(nodes: WBSNode[]): number {
|
||||||
|
// 从 store 中获取完整节点来递归计算
|
||||||
|
let total = 0;
|
||||||
|
for (const n of nodes) {
|
||||||
|
const children = Array.from(store.values()).filter(c => c.parentId === n.id);
|
||||||
|
if (children.length > 0) {
|
||||||
|
total += calculateTotalHours(children);
|
||||||
|
} else {
|
||||||
|
total += n.estimatedHours || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测循环依赖
|
||||||
|
*/
|
||||||
|
export function detectCircularDependency(nodes: WBSNode[]): string[] | null {
|
||||||
|
const graph = new Map<string, string[]>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
graph.set(n.id, n.dependencies.filter(d => graph.has(d) || nodes.some(x => x.id === d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const inStack = new Set<string>();
|
||||||
|
const cycle: string[] = [];
|
||||||
|
|
||||||
|
function dfs(id: string): boolean {
|
||||||
|
visited.add(id);
|
||||||
|
inStack.add(id);
|
||||||
|
for (const dep of graph.get(id) || []) {
|
||||||
|
if (!visited.has(dep)) {
|
||||||
|
if (dfs(dep)) { cycle.unshift(id); return true; }
|
||||||
|
} else if (inStack.has(dep)) {
|
||||||
|
cycle.unshift(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inStack.delete(id);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!visited.has(n.id)) {
|
||||||
|
if (dfs(n.id)) return cycle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扁平化 WBS 树
|
||||||
|
*/
|
||||||
|
export function flattenWBS(tree: Array<WBSNode & { children?: WBSNode[] }>): WBSNode[] {
|
||||||
|
const result: WBSNode[] = [];
|
||||||
|
function walk(nodes: Array<WBSNode & { children?: WBSNode[] }>) {
|
||||||
|
for (const n of nodes) {
|
||||||
|
const { children, ...rest } = n;
|
||||||
|
result.push(rest as WBSNode);
|
||||||
|
if (children?.length) walk(children as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(tree);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 拆解子任务(模拟)
|
||||||
|
*/
|
||||||
|
export function decomposeTask(task: { name: string; description: string }, level: number, parentCode: string, projectId: string): WBSNode[] {
|
||||||
|
// 根据任务名称模拟拆解
|
||||||
|
const templates: Record<string, string[]> = {
|
||||||
|
default: ['需求分析', '方案设计', '开发实现', '测试验证', '文档编写'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const subTasks = templates.default.slice(0, Math.min(3 + Math.floor(Math.random() * 3), 5));
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
return subTasks.map((name, i) => {
|
||||||
|
const id = `wbs-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
projectId,
|
||||||
|
parentId: null, // 调用者设置
|
||||||
|
name,
|
||||||
|
description: `${task.name} - ${name}`,
|
||||||
|
level: level + 1,
|
||||||
|
wbsCode: generateWBSCode(parentCode, i + 1),
|
||||||
|
estimatedHours: [4, 8, 16, 24, 40][Math.floor(Math.random() * 5)],
|
||||||
|
assignee: '',
|
||||||
|
assigneeType: 'ai' as const,
|
||||||
|
status: 'not_started' as const,
|
||||||
|
priority: 'medium' as const,
|
||||||
|
dependencies: i > 0 ? [] : [],
|
||||||
|
deliverables: [`${name}交付物`],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD
|
||||||
|
*/
|
||||||
|
export function createWBSNode(data: Omit<WBSNode, 'id' | 'createdAt' | 'updatedAt'>): WBSNode {
|
||||||
|
const id = `wbs-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const node: WBSNode = { ...data, id, createdAt: now, updatedAt: now };
|
||||||
|
store.set(id, node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectWBS(projectId: string): WBSNode[] {
|
||||||
|
return Array.from(store.values()).filter(n => n.projectId === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWBSNode(id: string): WBSNode | undefined {
|
||||||
|
return store.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWBSNode(id: string, data: Partial<Omit<WBSNode, 'id' | 'projectId' | 'createdAt'>>): WBSNode | null {
|
||||||
|
const node = store.get(id);
|
||||||
|
if (!node) return null;
|
||||||
|
const updated = { ...node, ...data, updatedAt: new Date().toISOString() };
|
||||||
|
store.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteWBSNode(id: string): number {
|
||||||
|
// 递归删除子节点
|
||||||
|
let count = 0;
|
||||||
|
const children = Array.from(store.values()).filter(n => n.parentId === id);
|
||||||
|
for (const child of children) {
|
||||||
|
count += deleteWBSNode(child.id);
|
||||||
|
}
|
||||||
|
store.delete(id);
|
||||||
|
return count + 1;
|
||||||
|
}
|
||||||
98
src/pages/ChangePage.tsx
Normal file
98
src/pages/ChangePage.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Table, Button, Modal, Form, Input, Select, Tag, Space, Message, Typography } from '@arco-design/web-react';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
interface ChangeRequest {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: 'scope' | 'schedule' | 'cost' | 'quality' | 'resource';
|
||||||
|
impactLevel?: string;
|
||||||
|
status: 'pending_approval' | 'approved' | 'rejected' | 'implemented';
|
||||||
|
submitter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = { pending_approval: 'orange', approved: 'green', rejected: 'red', implemented: 'blue' };
|
||||||
|
const STATUS_LABEL: Record<string, string> = { pending_approval: '待审批', approved: '已批准', rejected: '已驳回', implemented: '已执行' };
|
||||||
|
const TYPE_OPTIONS = ['scope', 'schedule', 'cost', 'quality', 'resource'].map(v => ({ label: v, value: v }));
|
||||||
|
|
||||||
|
export default function ChangePage() {
|
||||||
|
const [projectId] = useState('pmp-demo');
|
||||||
|
const [data, setData] = useState<ChangeRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const api = (path: string, opts?: RequestInit) => fetch(`/api/projects/${projectId}${path}`, opts);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try { const res = await api('/changes'); if (res.ok) { const d = await res.json(); setData(d?.changes || (Array.isArray(d) ? d : [])) }; } catch { Message.error('获取变更失败'); }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, []);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
try {
|
||||||
|
const res = await api('/changes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
|
||||||
|
if (res.ok) { Message.success('添加成功'); setModalVisible(false); form.resetFields(); fetchData(); }
|
||||||
|
} catch { Message.error('添加失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (cid: string, action: string) => {
|
||||||
|
try {
|
||||||
|
const res = await api(`/changes/${cid}/${action}`, { method: 'POST' });
|
||||||
|
if (res.ok) { Message.success('操作成功'); fetchData(); }
|
||||||
|
} catch { Message.error('操作失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/changes/stats');
|
||||||
|
if (res.ok) Modal.info({ title: '变更统计', content: JSON.stringify(await res.json(), null, 2) });
|
||||||
|
} catch { Message.error('获取统计失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '类型', dataIndex: 'type' },
|
||||||
|
{ title: '影响等级', dataIndex: 'impactLevel' },
|
||||||
|
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={STATUS_COLOR[v]}>{STATUS_LABEL[v]}</Tag> },
|
||||||
|
{ title: '提交人', dataIndex: 'submitter' },
|
||||||
|
{
|
||||||
|
title: '操作', render: (_: unknown, row: ChangeRequest) => (
|
||||||
|
<Space>
|
||||||
|
{row.status === 'pending_approval' && (
|
||||||
|
<>
|
||||||
|
<Button size="small" type="primary" onClick={() => handleAction(row.id, 'approve')}>批准</Button>
|
||||||
|
<Button size="small" status="danger" onClick={() => handleAction(row.id, 'reject')}>驳回</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{row.status === 'approved' && <Button size="small" type="primary" onClick={() => handleAction(row.id, 'implement')}>执行</Button>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<Title heading={4}>变更管理</Title>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" onClick={() => setModalVisible(true)}>添加变更</Button>
|
||||||
|
<Button onClick={handleStats}>统计</Button>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="id" columns={columns} data={data} loading={loading} />
|
||||||
|
<Modal title="添加变更" visible={modalVisible} onOk={handleAdd} onCancel={() => setModalVisible(false)}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="标题" field="title" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="描述" field="description"><TextArea /></Form.Item>
|
||||||
|
<Form.Item label="类型" field="type" rules={[{ required: true }]}><Select options={TYPE_OPTIONS} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/pages/HealthPage.tsx
Normal file
100
src/pages/HealthPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Button, Modal, Form, InputNumber, Space, Message, Typography, Table } from '@arco-design/web-react';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface DimensionScore {
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthReport {
|
||||||
|
id: string;
|
||||||
|
overallScore: number;
|
||||||
|
dimensions: DimensionScore[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIMENSION_NAMES = ['任务进度', '风险管理', '需求覆盖', '阻塞状况', '变更控制', '里程碑'];
|
||||||
|
const DIMENSION_KEYS = ['taskProgress', 'riskManagement', 'requirementCoverage', 'blockers', 'changeControl', 'milestones'];
|
||||||
|
|
||||||
|
function scoreColor(s: number) { return s >= 70 ? '#00b42a' : s >= 40 ? '#ff7d00' : '#f53f3f'; }
|
||||||
|
function trendIcon(t: string) { return t === 'up' ? '↑' : t === 'down' ? '↓' : '→'; }
|
||||||
|
|
||||||
|
export default function HealthPage() {
|
||||||
|
const [projectId] = useState('pmp-demo');
|
||||||
|
const [reports, setReports] = useState<HealthReport[]>([]);
|
||||||
|
const [current, setCurrent] = useState<HealthReport | null>(null);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const api = (path: string, opts?: RequestInit) => fetch(`/api/projects/${projectId}${path}`, opts);
|
||||||
|
|
||||||
|
const fetchReports = async () => {
|
||||||
|
try { const res = await api('/health-reports'); if (res.ok) { const d = await res.json(); setReports(d?.reports || (Array.isArray(d) ? d : [])) }; } catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchReports(); }, []);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
try {
|
||||||
|
const res = await api('/health-report', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
|
||||||
|
if (res.ok) { const d = await res.json(); setCurrent(d); Message.success('报告已生成'); setModalVisible(false); fetchReports(); }
|
||||||
|
} catch { Message.error('生成失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const overall = current?.overallScore ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<Title heading={4}>项目健康度</Title>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" onClick={() => setModalVisible(true)}>生成报告</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{current && (
|
||||||
|
<>
|
||||||
|
<Card style={{ textAlign: 'center', marginBottom: 16, background: scoreColor(overall), color: '#fff' }}>
|
||||||
|
<div style={{ fontSize: 48, fontWeight: 700 }}>{overall}</div>
|
||||||
|
<Text style={{ color: '#fff' }}>综合评分</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 24 }}>
|
||||||
|
{current.dimensions?.map((d, i) => (
|
||||||
|
<Card key={i} size="small">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text>{d.name || DIMENSION_NAMES[i]}</Text>
|
||||||
|
<Space>
|
||||||
|
<span style={{ width: 10, height: 10, borderRadius: '50%', background: scoreColor(d.score), display: 'inline-block' }} />
|
||||||
|
<span style={{ color: scoreColor(d.score), fontWeight: 600 }}>{d.score}</span>
|
||||||
|
<span>{trendIcon(d.trend)}</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Title heading={5}>历史报告</Title>
|
||||||
|
<Table rowKey="id" columns={[
|
||||||
|
{ title: 'ID', dataIndex: 'id' },
|
||||||
|
{ title: '综合评分', dataIndex: 'overallScore' },
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt' },
|
||||||
|
]} data={reports} />
|
||||||
|
|
||||||
|
<Modal title="生成健康报告" visible={modalVisible} onOk={handleGenerate} onCancel={() => setModalVisible(false)}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="总任务数" field="totalTasks"><InputNumber min={0} /></Form.Item>
|
||||||
|
<Form.Item label="已完成任务" field="completedTasks"><InputNumber min={0} /></Form.Item>
|
||||||
|
<Form.Item label="总需求数" field="totalRequirements"><InputNumber min={0} /></Form.Item>
|
||||||
|
<Form.Item label="已覆盖需求" field="coveredRequirements"><InputNumber min={0} /></Form.Item>
|
||||||
|
<Form.Item label="风险数" field="totalRisks"><InputNumber min={0} /></Form.Item>
|
||||||
|
<Form.Item label="已缓解风险" field="mitigatedRisks"><InputNumber min={0} /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/pages/KanbanPage.tsx
Normal file
8
src/pages/KanbanPage.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import KanbanBoard from '../components/KanbanBoard';
|
||||||
|
|
||||||
|
const KanbanPage: React.FC = () => {
|
||||||
|
return <KanbanBoard />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KanbanPage;
|
||||||
94
src/pages/RequirementPage.tsx
Normal file
94
src/pages/RequirementPage.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Table, Button, Modal, Form, Input, Select, Tag, Space, Message, Typography } from '@arco-design/web-react';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
interface Requirement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
priority: 'must' | 'should' | 'could' | 'wont';
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIORITY_COLOR: Record<string, string> = { must: 'red', should: 'orange', could: 'blue', wont: 'grey' };
|
||||||
|
const PRIORITY_LABEL: Record<string, string> = { must: 'Must', should: 'Should', could: 'Could', wont: "Won't" };
|
||||||
|
|
||||||
|
export default function RequirementPage() {
|
||||||
|
const [projectId] = useState('pmp-demo');
|
||||||
|
const [data, setData] = useState<Requirement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const api = (path: string, opts?: RequestInit) => fetch(`/api/projects/${projectId}${path}`, opts);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api('/requirements');
|
||||||
|
if (res.ok) { const d = await res.json(); setData(d?.requirements || (Array.isArray(d) ? d : [])) };
|
||||||
|
} catch { Message.error('获取需求失败'); }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, []);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
try {
|
||||||
|
const res = await api('/requirements', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
|
||||||
|
if (res.ok) { Message.success('添加成功'); setModalVisible(false); form.resetFields(); fetchData(); }
|
||||||
|
else Message.error('添加失败');
|
||||||
|
} catch { Message.error('添加失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverage = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/requirements/coverage');
|
||||||
|
if (res.ok) { const d = await res.json(); Modal.info({ title: '需求覆盖率', content: JSON.stringify(d, null, 2) }); }
|
||||||
|
} catch { Message.error('获取覆盖率失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConflict = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/requirements/detect-conflicts', { method: 'POST' });
|
||||||
|
if (res.ok) { const d = await res.json(); Modal.info({ title: '冲突检测结果', content: JSON.stringify(d, null, 2) }); }
|
||||||
|
} catch { Message.error('冲突检测失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', render: (v: string) => <Tag color={PRIORITY_COLOR[v]}>{PRIORITY_LABEL[v]}</Tag> },
|
||||||
|
{ title: '分类', dataIndex: 'category' },
|
||||||
|
{ title: '状态', dataIndex: 'status' },
|
||||||
|
{ title: '来源', dataIndex: 'source' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<Title heading={4}>需求池管理</Title>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" onClick={() => setModalVisible(true)}>添加需求</Button>
|
||||||
|
<Button onClick={handleCoverage}>覆盖率</Button>
|
||||||
|
<Button onClick={handleConflict}>冲突检测</Button>
|
||||||
|
</Space>
|
||||||
|
<Table rowKey="id" columns={columns} data={data} loading={loading} />
|
||||||
|
<Modal title="添加需求" visible={modalVisible} onOk={handleAdd} onCancel={() => setModalVisible(false)}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="标题" field="title" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="描述" field="description"><TextArea /></Form.Item>
|
||||||
|
<Form.Item label="优先级" field="priority" rules={[{ required: true }]}>
|
||||||
|
<Select options={['must', 'should', 'could', 'wont'].map(v => ({ label: PRIORITY_LABEL[v], value: v }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="分类" field="category">
|
||||||
|
<Select options={['功能', '性能', '安全', '体验', '其他'].map(v => ({ label: v, value: v }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
src/pages/RetrospectivePage.tsx
Normal file
140
src/pages/RetrospectivePage.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, Button, Modal, Form, Input, InputNumber, Space, Message, Typography, Tabs } from '@arco-design/web-react';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
interface RetroInput {
|
||||||
|
projectName: string;
|
||||||
|
goal: string;
|
||||||
|
period: string;
|
||||||
|
teamSize: number;
|
||||||
|
taskCompletionRate: number;
|
||||||
|
highlights?: string;
|
||||||
|
challenges?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetroResult {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
wentWell: string[];
|
||||||
|
toImprove: string[];
|
||||||
|
lessons: string[];
|
||||||
|
aiInsights?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnowledgeItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RetrospectivePage() {
|
||||||
|
const [projectId] = useState('pmp-demo');
|
||||||
|
const [result, setResult] = useState<RetroResult | null>(null);
|
||||||
|
const [history, setHistory] = useState<RetroResult[]>([]);
|
||||||
|
const [knowledge, setKnowledge] = useState<KnowledgeItem[]>([]);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const api = (path: string, opts?: RequestInit) => fetch(`/api/projects/${projectId}${path}`, opts);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [r1, r2] = await Promise.all([api('/retrospectives'), api('/knowledge')]);
|
||||||
|
if (r1.ok) setHistory(await r1.json());
|
||||||
|
if (r2.ok) setKnowledge(await r2.json());
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
try {
|
||||||
|
const res = await api('/retrospective', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(values) });
|
||||||
|
if (res.ok) { const d = await res.json(); setResult(d); Message.success('复盘完成'); setModalVisible(false); form.resetFields(); }
|
||||||
|
} catch { Message.error('复盘失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderList = (items: string[], color: string) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||||
|
{items.map((item, i) => <Card key={i} size="small" style={{ borderLeft: `3px solid ${color}` }}>{item}</Card>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<Title heading={4}>项目复盘</Title>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button type="primary" onClick={() => setModalVisible(true)}>开始复盘</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<Tabs defaultActiveTab="result">
|
||||||
|
<TabPane key="result" title="复盘结果">
|
||||||
|
<Card style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 56, fontWeight: 700, color: result.score >= 70 ? '#00b42a' : result.score >= 40 ? '#ff7d00' : '#f53f3f' }}>
|
||||||
|
{result.score}
|
||||||
|
</div>
|
||||||
|
<Text>综合评分</Text>
|
||||||
|
</Card>
|
||||||
|
<Title heading={6}>✅ 做得好的</Title>
|
||||||
|
{renderList(result.wentWell || [], '#00b42a')}
|
||||||
|
<Title heading={6} style={{ marginTop: 16 }}>⚠️ 待改进</Title>
|
||||||
|
{renderList(result.toImprove || [], '#ff7d00')}
|
||||||
|
<Title heading={6} style={{ marginTop: 16 }}>💡 经验教训</Title>
|
||||||
|
{renderList(result.lessons || [], '#165DFF')}
|
||||||
|
{result.aiInsights && (
|
||||||
|
<>
|
||||||
|
<Title heading={6} style={{ marginTop: 16 }}>🤖 AI 洞察</Title>
|
||||||
|
<Card size="small" style={{ background: '#f7f8fa' }}>{result.aiInsights}</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabPane>
|
||||||
|
<TabPane key="knowledge" title="知识库">
|
||||||
|
{knowledge.length === 0 ? <Text>暂无知识条目</Text> : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||||
|
{knowledge.map(item => (
|
||||||
|
<Card key={item.id} size="small" title={item.title}>
|
||||||
|
<Text>{item.content}</Text>
|
||||||
|
{item.tags && <div style={{ marginTop: 4 }}>{item.tags.map(t => <Text key={t} style={{ marginRight: 8, color: '#165DFF' }}>#{t}</Text>)}</div>}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!result && history.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Title heading={5}>历史复盘</Title>
|
||||||
|
{history.map(h => (
|
||||||
|
<Card key={h.id} size="small" style={{ marginBottom: 8 }}>
|
||||||
|
<Space>
|
||||||
|
<Text bold>评分: {h.score}</Text>
|
||||||
|
<Text type="secondary">{h.createdAt}</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal title="开始复盘" visible={modalVisible} onOk={handleStart} onCancel={() => setModalVisible(false)} style={{ width: 520 }}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="项目名称" field="projectName" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="项目目标" field="goal"><TextArea /></Form.Item>
|
||||||
|
<Form.Item label="项目周期" field="period"><Input placeholder="如: 2024-Q1" /></Form.Item>
|
||||||
|
<Form.Item label="团队规模" field="teamSize"><InputNumber min={1} /></Form.Item>
|
||||||
|
<Form.Item label="任务完成率(%)" field="taskCompletionRate"><InputNumber min={0} max={100} /></Form.Item>
|
||||||
|
<Form.Item label="亮点" field="highlights"><TextArea placeholder="做得好的方面..." /></Form.Item>
|
||||||
|
<Form.Item label="挑战" field="challenges"><TextArea placeholder="遇到的困难..." /></Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/pages/RiskPage.tsx
Normal file
154
src/pages/RiskPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, Table, Button, Modal, Form, Input, Select, Tag, Space, Message, InputNumber, Typography } from '@arco-design/web-react';
|
||||||
|
import { IconPlus, IconApps, IconSearch } from '@arco-design/web-react/icon';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface Risk {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
probability: number;
|
||||||
|
impact: number;
|
||||||
|
score: number;
|
||||||
|
level: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
critical: { label: '极高', color: 'red' },
|
||||||
|
high: { label: '高', color: 'orange' },
|
||||||
|
medium: { label: '中', color: 'gold' },
|
||||||
|
low: { label: '低', color: 'green' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '类别', dataIndex: 'category' },
|
||||||
|
{ title: '概率', dataIndex: 'probability' },
|
||||||
|
{ title: '影响', dataIndex: 'impact' },
|
||||||
|
{ title: '评分', dataIndex: 'score' },
|
||||||
|
{
|
||||||
|
title: '等级', dataIndex: 'level',
|
||||||
|
render: (v: string) => { const l = LEVEL_MAP[v]; return l ? <Tag color={l.color}>{l.label}</Tag> : v; },
|
||||||
|
},
|
||||||
|
{ title: '状态', dataIndex: 'status' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RiskPage() {
|
||||||
|
const [projectId, setProjectId] = useState('pmp-demo');
|
||||||
|
const [list, setList] = useState<Risk[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [identifyModal, setIdentifyModal] = useState(false);
|
||||||
|
const [matrix, setMatrix] = useState<string | null>(null);
|
||||||
|
const [projectDesc, setProjectDesc] = useState('');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const base = `/api/projects/${projectId}/risks`;
|
||||||
|
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(base);
|
||||||
|
const data = await res.json();
|
||||||
|
setList(data?.risks || data?.data || (Array.isArray(data) ? data : []));
|
||||||
|
} catch { Message.error('获取风险失败'); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [base]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchList(); }, [fetchList]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
try {
|
||||||
|
await form.validate();
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
values.score = values.probability * values.impact;
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
Message.success('添加成功');
|
||||||
|
setModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchList();
|
||||||
|
} catch { Message.error('添加失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIdentify = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/identify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description: projectDesc }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
Message.success('AI 识别完成');
|
||||||
|
setIdentifyModal(false);
|
||||||
|
setProjectDesc('');
|
||||||
|
fetchList();
|
||||||
|
} catch { Message.error('识别失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMatrix = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/matrix`);
|
||||||
|
const data = await res.json();
|
||||||
|
setMatrix(JSON.stringify(data?.data || data, null, 2));
|
||||||
|
} catch { Message.error('获取矩阵失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="风险管理" style={{ margin: 16 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input value={projectId} onChange={setProjectId} style={{ width: 160 }} placeholder="项目ID" />
|
||||||
|
<Button icon={<IconPlus />} type="primary" onClick={() => setModalVisible(true)}>添加</Button>
|
||||||
|
<Button icon={<IconSearch />} onClick={() => setIdentifyModal(true)}>AI识别风险</Button>
|
||||||
|
<Button icon={<IconApps />} onClick={handleMatrix}>风险矩阵</Button>
|
||||||
|
</Space>
|
||||||
|
}>
|
||||||
|
<Table columns={columns} data={list} loading={loading} rowKey="id" />
|
||||||
|
|
||||||
|
{matrix && (
|
||||||
|
<Card title="风险矩阵" style={{ marginTop: 16 }} extra={<Button size="small" onClick={() => setMatrix(null)}>关闭</Button>}>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 13 }}>{matrix}</pre>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal title="添加风险" visible={modalVisible} onOk={handleAdd} onCancel={() => { setModalVisible(false); form.resetFields(); }}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="标题" field="title" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="类别" field="category" rules={[{ required: true }]}>
|
||||||
|
<Select options={[
|
||||||
|
{ label: '技术', value: 'technical' },
|
||||||
|
{ label: '管理', value: 'management' },
|
||||||
|
{ label: '商业', value: 'business' },
|
||||||
|
{ label: '外部', value: 'external' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="概率 (1-5)" field="probability" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="影响 (1-5)" field="impact" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="状态" field="status" initialValue="open">
|
||||||
|
<Select options={[
|
||||||
|
{ label: '开放', value: 'open' },
|
||||||
|
{ label: '缓解中', value: 'mitigating' },
|
||||||
|
{ label: '已关闭', value: 'closed' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal title="AI 识别风险" visible={identifyModal} onOk={handleIdentify} onCancel={() => { setIdentifyModal(false); setProjectDesc(''); }}>
|
||||||
|
<Text>输入项目描述,AI 将识别潜在风险:</Text>
|
||||||
|
<TextArea rows={4} value={projectDesc} onChange={setProjectDesc} placeholder="描述项目范围、技术栈、团队情况..." style={{ marginTop: 8 }} />
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/pages/StakeholderPage.tsx
Normal file
123
src/pages/StakeholderPage.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, Table, Button, Modal, Form, Input, Select, Tag, Space, Message, InputNumber } from '@arco-design/web-react';
|
||||||
|
import { IconPlus, IconSearch } from '@arco-design/web-react/icon';
|
||||||
|
|
||||||
|
interface Stakeholder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
organization: string;
|
||||||
|
contact: string;
|
||||||
|
power: number;
|
||||||
|
interest: number;
|
||||||
|
category: string;
|
||||||
|
strategy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
manage_closely: { label: '重点管理', color: 'red' },
|
||||||
|
keep_satisfied: { label: '保持满意', color: 'orange' },
|
||||||
|
keep_informed: { label: '保持知情', color: 'arcoblue' },
|
||||||
|
monitor: { label: '监督', color: 'green' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '姓名', dataIndex: 'name' },
|
||||||
|
{ title: '角色', dataIndex: 'role' },
|
||||||
|
{ title: '权力', dataIndex: 'power' },
|
||||||
|
{ title: '利益', dataIndex: 'interest' },
|
||||||
|
{
|
||||||
|
title: '分类', dataIndex: 'category',
|
||||||
|
render: (v: string) => {
|
||||||
|
const c = CATEGORY_MAP[v];
|
||||||
|
return c ? <Tag color={c.color}>{c.label}</Tag> : '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '策略', dataIndex: 'strategy' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function StakeholderPage() {
|
||||||
|
const [projectId, setProjectId] = useState('pmp-demo');
|
||||||
|
const [list, setList] = useState<Stakeholder[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [analysis, setAnalysis] = useState<string | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const base = `/api/projects/${projectId}/stakeholders`;
|
||||||
|
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(base);
|
||||||
|
const data = await res.json();
|
||||||
|
setList(data?.stakeholders || data?.data || (Array.isArray(data) ? data : []));
|
||||||
|
} catch { Message.error('获取干系人失败'); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [base]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchList(); }, [fetchList]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
try {
|
||||||
|
await form.validate();
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
Message.success('添加成功');
|
||||||
|
setModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchList();
|
||||||
|
} catch { Message.error('添加失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/analyze`, { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
setAnalysis(JSON.stringify(data?.data || data, null, 2));
|
||||||
|
} catch { Message.error('分析失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="干系人管理" style={{ margin: 16 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input value={projectId} onChange={setProjectId} style={{ width: 160 }} placeholder="项目ID" />
|
||||||
|
<Button icon={<IconPlus />} type="primary" onClick={() => setModalVisible(true)}>添加</Button>
|
||||||
|
<Button icon={<IconSearch />} onClick={handleAnalyze}>分析</Button>
|
||||||
|
</Space>
|
||||||
|
}>
|
||||||
|
<Table columns={columns} data={list} loading={loading} rowKey="id" />
|
||||||
|
|
||||||
|
{analysis && (
|
||||||
|
<Card title="分析结果" style={{ marginTop: 16 }} extra={<Button size="small" onClick={() => setAnalysis(null)}>关闭</Button>}>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', fontSize: 13 }}>{analysis}</pre>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal title="添加干系人" visible={modalVisible} onOk={handleAdd} onCancel={() => { setModalVisible(false); form.resetFields(); }}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="姓名" field="name" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="角色" field="role" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="组织" field="organization"><Input /></Form.Item>
|
||||||
|
<Form.Item label="联系方式" field="contact"><Input /></Form.Item>
|
||||||
|
<Form.Item label="权力 (1-5)" field="power" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="利益 (1-5)" field="interest" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={5} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/pages/WBSPage.tsx
Normal file
139
src/pages/WBSPage.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, Button, Modal, Form, Input, Select, Tag, Space, Message, InputNumber, Typography } from '@arco-design/web-react';
|
||||||
|
import { IconPlus, IconSearch } from '@arco-design/web-react/icon';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface WBSNode {
|
||||||
|
id: string;
|
||||||
|
wbsCode: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
level: number;
|
||||||
|
status: string;
|
||||||
|
estimatedHours: number;
|
||||||
|
assignee: string;
|
||||||
|
priority: string;
|
||||||
|
children?: WBSNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
pending: { label: '待开始', color: 'grey' },
|
||||||
|
in_progress: { label: '进行中', color: 'blue' },
|
||||||
|
done: { label: '已完成', color: 'green' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_MAP: Record<string, { label: string; color: string }> = {
|
||||||
|
must: { label: '必须', color: 'red' },
|
||||||
|
should: { label: '应该', color: 'orange' },
|
||||||
|
could: { label: '可以', color: 'blue' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const WBSRow: React.FC<{ node: WBSNode; projectId: string; onRefresh: () => void }> = ({ node, projectId, onRefresh }) => {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
const handleDecompose = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/wbs/${node.id}/decompose`, { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
Message.success('AI 拆解已提交');
|
||||||
|
onRefresh();
|
||||||
|
} catch { Message.error('拆解失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = STATUS_MAP[node.status] || { label: node.status, color: 'grey' };
|
||||||
|
const p = PRIORITY_MAP[node.priority] || { label: node.priority, color: 'grey' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--color-border-1)' }}>
|
||||||
|
<div style={{ width: 24 * node.level }}>
|
||||||
|
{node.children?.length ? (
|
||||||
|
<Text onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer' }}>{expanded ? '▼' : '▶'}</Text>
|
||||||
|
) : <span style={{ marginLeft: 12 }} />}
|
||||||
|
</div>
|
||||||
|
<Text style={{ width: 80, flexShrink: 0 }}>{node.wbsCode}</Text>
|
||||||
|
<Text style={{ flex: 1 }}>{node.name}</Text>
|
||||||
|
<Tag color={s.color} size="small">{s.label}</Tag>
|
||||||
|
<Text style={{ margin: '0 12px', flexShrink: 0 }}>{node.estimatedHours}h</Text>
|
||||||
|
<Text style={{ width: 80, flexShrink: 0 }}>{node.assignee || '-'}</Text>
|
||||||
|
<Tag color={p.color} size="small" style={{ margin: '0 8px' }}>{p.label}</Tag>
|
||||||
|
<Button size="mini" icon={<IconSearch />} onClick={handleDecompose}>AI拆解</Button>
|
||||||
|
</div>
|
||||||
|
{expanded && node.children?.map(child => (
|
||||||
|
<WBSRow key={child.id} node={child} projectId={projectId} onRefresh={onRefresh} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WBSPage() {
|
||||||
|
const [projectId, setProjectId] = useState('pmp-demo');
|
||||||
|
const [nodes, setNodes] = useState<WBSNode[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const base = `/api/projects/${projectId}/wbs`;
|
||||||
|
|
||||||
|
const fetchNodes = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(base);
|
||||||
|
const data = await res.json();
|
||||||
|
setNodes(data?.tree || data?.data || (Array.isArray(data) ? data : []));
|
||||||
|
} catch { Message.error('获取 WBS 失败'); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [base]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchNodes(); }, [fetchNodes]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
try {
|
||||||
|
await form.validate();
|
||||||
|
const res = await fetch(base, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(form.getFieldsValue()),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
Message.success('添加成功');
|
||||||
|
setModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
fetchNodes();
|
||||||
|
} catch { Message.error('添加失败'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="WBS 任务分解" style={{ margin: 16 }}
|
||||||
|
extra={
|
||||||
|
<Space>
|
||||||
|
<Input value={projectId} onChange={setProjectId} style={{ width: 160 }} placeholder="项目ID" />
|
||||||
|
<Button icon={<IconPlus />} type="primary" onClick={() => setModalVisible(true)}>添加节点</Button>
|
||||||
|
</Space>
|
||||||
|
}>
|
||||||
|
{loading ? <Text>加载中...</Text> : nodes.map(n => (
|
||||||
|
<WBSRow key={n.id} node={n} projectId={projectId} onRefresh={fetchNodes} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Modal title="添加 WBS 节点" visible={modalVisible} onOk={handleAdd} onCancel={() => { setModalVisible(false); form.resetFields(); }}>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item label="名称" field="name" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item label="描述" field="description"><Input /></Form.Item>
|
||||||
|
<Form.Item label="层级" field="level" rules={[{ required: true }]}>
|
||||||
|
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="预估工时" field="estimatedHours">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="优先级" field="priority">
|
||||||
|
<Select options={[
|
||||||
|
{ label: '必须', value: 'must' },
|
||||||
|
{ label: '应该', value: 'should' },
|
||||||
|
{ label: '可以', value: 'could' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,22 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { serve } from '@hono/node-server';
|
||||||
import { executionApiHandlers } from './execution-api';
|
import { executionApiHandlers } from './execution-api';
|
||||||
import { handleDecompose, handleFeishuCallback } from './index';
|
import { handleDecompose } from './index';
|
||||||
import { sendFeishuMessage, notifyProjectCreated, notifyMilestoneReminder, notifyRiskAlert, sendDecisionCard } from './feishu';
|
import {
|
||||||
import { handleDecisionCallback, createDecision, getPendingDecisions, DECISION_TEMPLATES } from '../lib/decision-cards';
|
notifyProjectCreated,
|
||||||
|
onMessage,
|
||||||
|
onCardAction,
|
||||||
|
handleFeishuEvent,
|
||||||
|
handleCardCallback,
|
||||||
|
replyMessage,
|
||||||
|
updateCard,
|
||||||
|
FeishuMessageEvent,
|
||||||
|
FeishuCardActionRequest,
|
||||||
|
} from './feishu';
|
||||||
|
import { handleDecisionCallback, createDecision, getPendingDecisions } from '../lib/decision-cards';
|
||||||
|
|
||||||
const projects: Record<string, any> = {};
|
const projects: Record<string, any> = {};
|
||||||
|
|
||||||
@@ -23,7 +35,6 @@ app.post('/api/projects', async (c) => {
|
|||||||
const project = { id: projectId, ...body, status: 'active', createdAt: new Date().toISOString() };
|
const project = { id: projectId, ...body, status: 'active', createdAt: new Date().toISOString() };
|
||||||
projects[projectId] = project;
|
projects[projectId] = project;
|
||||||
|
|
||||||
// Send Feishu notification
|
|
||||||
try {
|
try {
|
||||||
await notifyProjectCreated(project.name || '未命名项目', project.goal || '', 'ou_41d14aca8278e605d98e33b1221777e4', 'open_id');
|
await notifyProjectCreated(project.name || '未命名项目', project.goal || '', 'ou_41d14aca8278e605d98e33b1221777e4', 'open_id');
|
||||||
console.log('✅ Feishu notification sent for project:', project.name);
|
console.log('✅ Feishu notification sent for project:', project.name);
|
||||||
@@ -59,14 +70,304 @@ app.post('/api/projects/:id/decompose', async (c) => {
|
|||||||
app.get('/api/projects/:id/decisions', (c) => c.json({ decisions: executionApiHandlers.getDecisions(c.req.param('id')) }));
|
app.get('/api/projects/:id/decisions', (c) => c.json({ decisions: executionApiHandlers.getDecisions(c.req.param('id')) }));
|
||||||
app.post('/api/projects/:id/decisions', async (c) => { const b = await c.req.json(); return c.json(executionApiHandlers.createDecision(c.req.param('id'), b), 201); });
|
app.post('/api/projects/:id/decisions', async (c) => { const b = await c.req.json(); return c.json(executionApiHandlers.createDecision(c.req.param('id'), b), 201); });
|
||||||
|
|
||||||
app.post('/api/feishu/webhook', async (c) => { const e = await c.req.json(); return c.json(handleFeishuCallback(e)); });
|
// ============================================================
|
||||||
app.post('/api/feishu/decision/callback', async (c) => { const b = await c.req.json(); console.log('Decision callback:', JSON.stringify(b)); return c.json({ ok: true }); });
|
// 飞书消息处理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
onMessage(async (event: FeishuMessageEvent) => {
|
||||||
|
const { message, sender } = event;
|
||||||
|
const msgType = message.message_type;
|
||||||
|
const chatType = message.chat_type;
|
||||||
|
const openId = sender.sender_id.open_id;
|
||||||
|
|
||||||
|
if (msgType !== 'text') {
|
||||||
|
await replyMessage(message.message_id, '暂只支持文本消息,敬请谅解 🙏');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message.content);
|
||||||
|
text = (parsed.text || '').trim();
|
||||||
|
} catch {
|
||||||
|
text = message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Feishu] Text message: "${text}" from ${openId} (${chatType})`);
|
||||||
|
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
|
||||||
|
if (lower === '/help' || lower === '帮助') {
|
||||||
|
await replyMessage(message.message_id,
|
||||||
|
'📋 FlowPilot 指令列表:\n' +
|
||||||
|
'• /help - 显示帮助\n' +
|
||||||
|
'• /status - 查看项目状态\n' +
|
||||||
|
'• /decisions - 查看待决策\n' +
|
||||||
|
'• /tasks - 查看任务列表\n' +
|
||||||
|
'\n也可以直接发消息,我会尝试处理。'
|
||||||
|
);
|
||||||
|
} else if (lower === '/status' || lower === '状态') {
|
||||||
|
const projectCount = Object.keys(projects).length;
|
||||||
|
await replyMessage(message.message_id,
|
||||||
|
`📊 FlowPilot 状态:\n` +
|
||||||
|
`• 项目数:${projectCount}\n` +
|
||||||
|
`• 版本:v0.5.0\n` +
|
||||||
|
`• 状态:运行中 ✅`
|
||||||
|
);
|
||||||
|
} else if (lower === '/decisions' || lower === '决策') {
|
||||||
|
const pending = getPendingDecisions();
|
||||||
|
if (pending.length === 0) {
|
||||||
|
await replyMessage(message.message_id, '当前没有待决策项 ✅');
|
||||||
|
} else {
|
||||||
|
const list = pending.map((d, i) => `${i + 1}. ${d.cardTitle}(${d.type})`).join('\n');
|
||||||
|
await replyMessage(message.message_id, `⏳ 待决策项:\n${list}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await replyMessage(message.message_id, `收到:${text}\n输入 /help 查看可用指令。`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 飞书卡片按钮回调
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
onCardAction(async (action: FeishuCardActionRequest) => {
|
||||||
|
const value = action.action!.value;
|
||||||
|
const decisionId = value.decisionId || '';
|
||||||
|
const actionKey = value.action || '';
|
||||||
|
|
||||||
|
console.log(`[Feishu] Card button clicked: decisionId=${decisionId}, action=${actionKey}, user=${action.open_id}`);
|
||||||
|
|
||||||
|
if (!decisionId) {
|
||||||
|
return { toast: { type: 'error' as const, content: '无效的决策' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = handleDecisionCallback(decisionId, actionKey);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { toast: { type: 'error' as const, content: result.error || '处理失败' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionLabel = actionKey === 'approve' ? '✅ 已批准'
|
||||||
|
: actionKey === 'reject' ? '❌ 已驳回'
|
||||||
|
: `已选择:${actionKey}`;
|
||||||
|
|
||||||
|
const updatedCard = {
|
||||||
|
header: {
|
||||||
|
title: { tag: 'plain_text', content: `${result.record?.cardTitle || '决策'} - ${optionLabel}` },
|
||||||
|
template: actionKey === 'approve' ? 'green' : actionKey === 'reject' ? 'red' : 'blue',
|
||||||
|
},
|
||||||
|
elements: [
|
||||||
|
{ tag: 'div', text: { tag: 'plain_text', content: `决策结果:${optionLabel}\n操作人:${action.open_id}\n时间:${new Date().toLocaleString('zh-CN')}` } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateCard(action.open_message_id, updatedCard);
|
||||||
|
|
||||||
|
return { toast: { type: 'success' as const, content: optionLabel } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 飞书路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/feishu/event', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const h of ['x-lark-signature', 'x-lark-request-timestamp', 'x-lark-request-nonce']) {
|
||||||
|
const v = c.req.header(h);
|
||||||
|
if (v) headers[h] = v;
|
||||||
|
}
|
||||||
|
const result = await handleFeishuEvent(body, headers);
|
||||||
|
return c.json(result.body, result.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/feishu/card', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const result = await handleCardCallback(body);
|
||||||
|
return c.json(result.body, result.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 兼容旧路由
|
||||||
|
app.post('/api/feishu/webhook', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const h of ['x-lark-signature', 'x-lark-request-timestamp', 'x-lark-request-nonce']) {
|
||||||
|
const v = c.req.header(h);
|
||||||
|
if (v) headers[h] = v;
|
||||||
|
}
|
||||||
|
const result = await handleFeishuEvent(body, headers);
|
||||||
|
return c.json(result.body, result.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/feishu/decision/callback', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const result = await handleCardCallback(body);
|
||||||
|
return c.json(result.body, result.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 多模型路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllModels, getModel, updateModel, selectModel, recordCall, getCostStats, compareModels,
|
||||||
|
} from '../lib/multi-model';
|
||||||
|
|
||||||
|
app.get('/api/models', (c) => c.json(getAllModels()));
|
||||||
|
|
||||||
|
app.get('/api/models/:modelId', (c) => {
|
||||||
|
const model = getModel(c.req.param('modelId'));
|
||||||
|
return model ? c.json(model) : c.json({ error: 'Model not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/models/:modelId', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const updated = updateModel(c.req.param('modelId'), body);
|
||||||
|
return updated ? c.json(updated) : c.json({ error: 'Model not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/models/select', async (c) => {
|
||||||
|
const { taskType, strategy } = await c.req.json();
|
||||||
|
try {
|
||||||
|
const model = selectModel(taskType, strategy);
|
||||||
|
return c.json(model);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ error: e.message }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/model-costs', (c) => {
|
||||||
|
const from = c.req.query('from');
|
||||||
|
const to = c.req.query('to');
|
||||||
|
const period = (from && to) ? { from, to } : undefined;
|
||||||
|
return c.json(getCostStats(c.req.param('id'), period));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/model-comparison', (c) => c.json(compareModels()));
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/model-calls', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const entry = recordCall({ ...body, projectId: c.req.param('id') });
|
||||||
|
return c.json(entry, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
import {
|
||||||
|
createChangeRequest, getProjectChanges, getChange, approveChange, rejectChange, implementChange, updateChange, deleteChange, getChangeStats,
|
||||||
|
} from '../lib/change';
|
||||||
|
import { generateHealthReport, getProjectReports, getReport } from '../lib/health-report';
|
||||||
|
import { generateRetrospective, addKnowledge, getProjectKnowledge, getKnowledge, searchKnowledge, getProjectRetrospectives, getRetrospective } from '../lib/retrospective';
|
||||||
|
|
||||||
|
// 干系人路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
createStakeholder, getProjectStakeholders, getStakeholder, updateStakeholder, deleteStakeholder, generateStakeholderAnalysis, recommendStrategy,
|
||||||
|
} from '../lib/stakeholder';
|
||||||
|
import {
|
||||||
|
createWBSNode, getProjectWBS, getWBSNode, updateWBSNode, deleteWBSNode, buildWBSTree, decomposeTask,
|
||||||
|
} from '../lib/wbs';
|
||||||
|
import {
|
||||||
|
createRisk, getRisksByProject, getRisk, updateRisk, deleteRisk, identifyRisks, recommendResponse, generateRiskMatrix,
|
||||||
|
} from '../lib/risk';
|
||||||
|
import {
|
||||||
|
createRequirement, getRequirements, getRequirement, updateRequirement, deleteRequirement, analyzeRequirement, detectConflicts as detectRequirementConflicts, generateCoverageReport, generateUserStory, sortByMoSCoW,
|
||||||
|
} from '../lib/requirement';
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/stakeholders', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
return c.json(createStakeholder({ ...body, projectId: c.req.param('id') }), 201);
|
||||||
|
});
|
||||||
|
app.get('/api/projects/:id/stakeholders', (c) => c.json({ stakeholders: getProjectStakeholders(c.req.param('id')) }));
|
||||||
|
app.get('/api/projects/:id/stakeholders/:sid', (c) => { const s = getStakeholder(c.req.param('sid')); return s ? c.json(s) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.patch('/api/projects/:id/stakeholders/:sid', async (c) => { const body = await c.req.json(); const u = updateStakeholder(c.req.param('sid'), body); return u ? c.json(u) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.delete('/api/projects/:id/stakeholders/:sid', (c) => deleteStakeholder(c.req.param('sid')) ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404));
|
||||||
|
app.post('/api/projects/:id/stakeholders/analyze', (c) => c.json(generateStakeholderAnalysis(getProjectStakeholders(c.req.param('id')))));
|
||||||
|
app.post('/api/projects/:id/stakeholders/:sid/strategy', (c) => { const s = getStakeholder(c.req.param('sid')); if (!s) return c.json({ error: 'Not found' }, 404); return c.json(recommendStrategy(s.category, s.role)); });
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/wbs', async (c) => { const body = await c.req.json(); return c.json(createWBSNode({ ...body, projectId: c.req.param('id') }), 201); });
|
||||||
|
app.get('/api/projects/:id/wbs', (c) => { const nodes = getProjectWBS(c.req.param('id')); return c.json({ tree: buildWBSTree(nodes), total: nodes.length }); });
|
||||||
|
app.get('/api/projects/:id/wbs/:nodeId', (c) => { const n = getWBSNode(c.req.param('nodeId')); return n ? c.json(n) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.patch('/api/projects/:id/wbs/:nodeId', async (c) => { const body = await c.req.json(); const u = updateWBSNode(c.req.param('nodeId'), body); return u ? c.json(u) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.delete('/api/projects/:id/wbs/:nodeId', (c) => c.json({ ok: true, deletedCount: deleteWBSNode(c.req.param('nodeId')) }));
|
||||||
|
app.post('/api/projects/:id/wbs/:nodeId/decompose', async (c) => {
|
||||||
|
const parent = getWBSNode(c.req.param('nodeId'));
|
||||||
|
if (!parent) return c.json({ error: 'Not found' }, 404);
|
||||||
|
const children = decomposeTask({ name: parent.name, description: parent.description }, parent.level, parent.wbsCode, parent.projectId);
|
||||||
|
const saved = children.map(child => createWBSNode({ ...child, parentId: parent.id }));
|
||||||
|
return c.json({ parent: parent.id, children: saved });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/risks', async (c) => { const body = await c.req.json(); return c.json(createRisk(c.req.param("id"), body), 201); });
|
||||||
|
app.get('/api/projects/:id/risks', (c) => {
|
||||||
|
let list = getRisksByProject(c.req.param('id'));
|
||||||
|
const level = c.req.query('level'); if (level) list = list.filter((r: any) => r.level === level);
|
||||||
|
const status = c.req.query('status'); if (status) list = list.filter((r: any) => r.status === status);
|
||||||
|
return c.json({ risks: list });
|
||||||
|
});
|
||||||
|
app.get('/api/projects/:id/risks/:rid', (c) => { const r = getRisk(c.req.param('rid')); return r ? c.json(r) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.patch('/api/projects/:id/risks/:rid', async (c) => { const body = await c.req.json(); const u = updateRisk(c.req.param('rid'), body); return u ? c.json(u) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.delete('/api/projects/:id/risks/:rid', (c) => deleteRisk(c.req.param('rid')) ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404));
|
||||||
|
app.post('/api/projects/:id/risks/identify', async (c) => { const { description, type } = await c.req.json(); return c.json({ risks: identifyRisks(description || '', type) }); });
|
||||||
|
app.post('/api/projects/:id/risks/:rid/respond', (c) => { const r = getRisk(c.req.param('rid')); if (!r) return c.json({ error: 'Not found' }, 404); return c.json(recommendResponse(r)); });
|
||||||
|
app.get('/api/projects/:id/risks/matrix', (c) => c.json(generateRiskMatrix(getRisksByProject(c.req.param('id')))));
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/requirements', async (c) => { const body = await c.req.json(); return c.json(createRequirement(c.req.param("id"), body), 201); });
|
||||||
|
app.get('/api/projects/:id/requirements', (c) => {
|
||||||
|
let list = getRequirements(c.req.param('id'));
|
||||||
|
const p = c.req.query('priority'); if (p) list = list.filter((r: any) => r.priority === p);
|
||||||
|
const s = c.req.query('status'); if (s) list = list.filter((r: any) => r.status === s);
|
||||||
|
const cat = c.req.query('category'); if (cat) list = list.filter((r: any) => r.category === cat);
|
||||||
|
return c.json({ requirements: sortByMoSCoW(list) });
|
||||||
|
});
|
||||||
|
app.get('/api/projects/:id/requirements/:rid', (c) => { const r = getRequirement(c.req.param('rid')); return r ? c.json(r) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.patch('/api/projects/:id/requirements/:rid', async (c) => { const body = await c.req.json(); const u = updateRequirement(c.req.param('rid'), body); return u ? c.json(u) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.delete('/api/projects/:id/requirements/:rid', (c) => deleteRequirement(c.req.param('rid')) ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404));
|
||||||
|
app.post('/api/projects/:id/requirements/analyze', async (c) => c.json(analyzeRequirement(await c.req.json())));
|
||||||
|
app.post('/api/projects/:id/requirements/detect-conflicts', (c) => c.json({ conflicts: detectRequirementConflicts(getRequirements(c.req.param('id'))) }));
|
||||||
|
app.get('/api/projects/:id/requirements/coverage', (c) => c.json(generateCoverageReport(getRequirements(c.req.param('id')))));
|
||||||
|
app.post('/api/projects/:id/requirements/:rid/user-story', (c) => {
|
||||||
|
const r = getRequirement(c.req.param('rid')); if (!r) return c.json({ error: 'Not found' }, 404);
|
||||||
|
const story = generateUserStory(r.description);
|
||||||
|
const u = updateRequirement(c.req.param('rid'), { userStory: story } as any);
|
||||||
|
return c.json(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 变更管理路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/changes', async (c) => { const body = await c.req.json(); return c.json(createChangeRequest(c.req.param('id'), body), 201); });
|
||||||
|
app.get('/api/projects/:id/changes', (c) => { const type = c.req.query('type') as any; const status = c.req.query('status') as any; return c.json({ changes: getProjectChanges(c.req.param('id'), { type, status }) }); });
|
||||||
|
app.get('/api/projects/:id/changes/stats', (c) => c.json(getChangeStats(c.req.param('id'))));
|
||||||
|
app.get('/api/projects/:id/changes/:cid', (c) => { const cr = getChange(c.req.param('cid')); return cr ? c.json(cr) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.patch('/api/projects/:id/changes/:cid', async (c) => { const body = await c.req.json(); const u = updateChange(c.req.param('cid'), body); return u ? c.json(u) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.delete('/api/projects/:id/changes/:cid', (c) => deleteChange(c.req.param('cid')) ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404));
|
||||||
|
app.post('/api/projects/:id/changes/:cid/approve', async (c) => { const { approver } = await c.req.json(); const cr = approveChange(c.req.param('cid'), approver); return cr ? c.json(cr) : c.json({ error: 'Cannot approve' }, 400); });
|
||||||
|
app.post('/api/projects/:id/changes/:cid/reject', async (c) => { const { reason } = await c.req.json(); const cr = rejectChange(c.req.param('cid'), reason || ''); return cr ? c.json(cr) : c.json({ error: 'Cannot reject' }, 400); });
|
||||||
|
app.post('/api/projects/:id/changes/:cid/implement', async (c) => { const { notes } = await c.req.json(); const cr = implementChange(c.req.param('cid'), notes || ''); return cr ? c.json(cr) : c.json({ error: 'Cannot implement' }, 400); });
|
||||||
|
|
||||||
|
// 健康度报告
|
||||||
|
app.post('/api/projects/:id/health-report', async (c) => { const stats = await c.req.json(); return c.json(generateHealthReport(c.req.param('id'), stats), 201); });
|
||||||
|
app.get('/api/projects/:id/health-reports', (c) => c.json({ reports: getProjectReports(c.req.param('id')) }));
|
||||||
|
app.get('/api/projects/:id/health-reports/:rid', (c) => { const r = getReport(c.req.param('rid')); return r ? c.json(r) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
|
||||||
|
// 复盘与知识库
|
||||||
|
app.post('/api/projects/:id/retrospective', async (c) => { const data = await c.req.json(); return c.json(generateRetrospective(c.req.param('id'), data), 201); });
|
||||||
|
app.get('/api/projects/:id/retrospectives', (c) => c.json({ retrospectives: getProjectRetrospectives(c.req.param('id')) }));
|
||||||
|
app.get('/api/projects/:id/retrospectives/:rid', (c) => { const r = getRetrospective(c.req.param('rid')); return r ? c.json(r) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
app.post('/api/projects/:id/knowledge', async (c) => { const body = await c.req.json(); return c.json(addKnowledge({ ...body, projectId: c.req.param('id') }), 201); });
|
||||||
|
app.get('/api/projects/:id/knowledge', (c) => { const type = c.req.query('type') as any; return c.json({ knowledge: getProjectKnowledge(c.req.param('id'), type) }); });
|
||||||
|
app.get('/api/knowledge/search', (c) => { const q = c.req.query('q') || ''; const limit = Number(c.req.query('limit')) || 10; return c.json({ results: searchKnowledge(q, limit) }); });
|
||||||
|
app.get('/api/knowledge/:kid', (c) => { const e = getKnowledge(c.req.param('kid')); return e ? c.json(e) : c.json({ error: 'Not found' }, 404); });
|
||||||
|
|
||||||
|
// 启动飞书长连接
|
||||||
|
import { startFeishuWS, setProjectsStore } from './feishu-ws';
|
||||||
|
setProjectsStore(projects);
|
||||||
|
startFeishuWS().catch(err => console.error('❌ Feishu WS 启动失败:', err));
|
||||||
|
|
||||||
// Start with Node.js native http
|
|
||||||
const port = Number(process.env.PORT) || 3001;
|
const port = Number(process.env.PORT) || 3001;
|
||||||
import { createServer } from 'http';
|
|
||||||
import { serve } from '@hono/node-server';
|
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port }, () => {
|
serve({ fetch: app.fetch, port }, () => {
|
||||||
console.log(`🚀 FlowPilot API running on http://localhost:${port}`);
|
console.log(`🚀 FlowPilot API running on http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
240
src/server/feishu-ws.ts
Normal file
240
src/server/feishu-ws.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* 飞书长连接客户端
|
||||||
|
*
|
||||||
|
* 使用 @larksuiteoapi/node-sdk 的 WSClient 建立长连接接收事件,
|
||||||
|
* 无需公网域名即可接收飞书消息和卡片回调。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client, WSClient, EventDispatcher, Domain } from '@larksuiteoapi/node-sdk';
|
||||||
|
import { replyMessage, updateCard } from './feishu';
|
||||||
|
import { handleDecisionCallback, getPendingDecisions } from '../lib/decision-cards';
|
||||||
|
|
||||||
|
const FEISHU_APP_ID = process.env.FEISHU_APP_ID || 'cli_a95093447cb85cdd';
|
||||||
|
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET || 'd17CeffVfOnTkQo8LIP7hbhOQwSPv7Jv';
|
||||||
|
const FEISHU_VERIFICATION_TOKEN = process.env.FEISHU_VERIFICATION_TOKEN || '';
|
||||||
|
|
||||||
|
// 飞书 API Client(用于主动调用 API)
|
||||||
|
export const feishuClient = new Client({
|
||||||
|
appId: FEISHU_APP_ID,
|
||||||
|
appSecret: FEISHU_APP_SECRET,
|
||||||
|
domain: Domain.Feishu,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 项目存储引用(由 dev.ts/main.ts 注入)
|
||||||
|
let projectsStore: Record<string, any> = {};
|
||||||
|
|
||||||
|
export function setProjectsStore(store: Record<string, any>): void {
|
||||||
|
projectsStore = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理接收到的文本消息
|
||||||
|
*/
|
||||||
|
async function handleIncomingMessage(data: any): Promise<void> {
|
||||||
|
const msgType = data.message?.message_type;
|
||||||
|
const chatType = data.message?.chat_type;
|
||||||
|
const openId = data.sender?.sender_id?.open_id || '';
|
||||||
|
const messageId = data.message?.message_id || '';
|
||||||
|
|
||||||
|
console.log(`[Feishu WS] Message received: type=${msgType}, chat=${chatType}, from=${openId}`);
|
||||||
|
|
||||||
|
// 只处理文本消息
|
||||||
|
if (msgType !== 'text') {
|
||||||
|
await replyMessage(messageId, '暂只支持文本消息,敬请谅解 🙏');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析文本
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.message.content);
|
||||||
|
text = (parsed.text || '').trim();
|
||||||
|
} catch {
|
||||||
|
text = data.message.content || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Feishu WS] Text: "${text}" from ${openId}`);
|
||||||
|
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
|
||||||
|
if (lower === '/help' || lower === '帮助') {
|
||||||
|
await replyMessage(messageId,
|
||||||
|
'📋 FlowPilot 指令列表:\n' +
|
||||||
|
'• /help - 显示帮助\n' +
|
||||||
|
'• /status - 查看项目状态\n' +
|
||||||
|
'• /decisions - 查看待决策\n' +
|
||||||
|
'• /tasks - 查看任务列表\n' +
|
||||||
|
'• /stakeholders [项目ID] - 干系人列表\n' +
|
||||||
|
'• /risks [项目ID] - 风险列表\n' +
|
||||||
|
'• /requirements [项目ID] - 需求列表\n' +
|
||||||
|
'• /changes [项目ID] - 变更列表\n' +
|
||||||
|
'• /health [项目ID] - 健康度报告\n' +
|
||||||
|
'• /retro [项目ID] - 复盘报告\n' +
|
||||||
|
'• /models - 可用模型列表\n' +
|
||||||
|
'\n默认项目: pmp-demo'
|
||||||
|
);
|
||||||
|
} else if (lower === '/status' || lower === '状态') {
|
||||||
|
const projectCount = Object.keys(projectsStore).length;
|
||||||
|
await replyMessage(messageId,
|
||||||
|
`📊 FlowPilot 状态:\n` +
|
||||||
|
`• 项目数:${projectCount}\n` +
|
||||||
|
`• 版本:v0.5.0\n` +
|
||||||
|
`• 状态:运行中 ✅`
|
||||||
|
);
|
||||||
|
} else if (lower === '/decisions' || lower === '决策') {
|
||||||
|
const pending = getPendingDecisions();
|
||||||
|
if (pending.length === 0) {
|
||||||
|
await replyMessage(messageId, '当前没有待决策项 ✅');
|
||||||
|
} else {
|
||||||
|
const list = pending.map((d: any, i: number) => `${i + 1}. ${d.cardTitle}(${d.type})`).join('\n');
|
||||||
|
await replyMessage(messageId, `⏳ 待决策项:\n${list}`);
|
||||||
|
}
|
||||||
|
} else if (lower.startsWith('/stakeholders') || lower.startsWith('干系人')) {
|
||||||
|
// /stakeholders [projectId]
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
const pid = parts[1] || 'pmp-demo';
|
||||||
|
const res = await fetch(`http://localhost:3001/api/projects/${pid}/stakeholders`);
|
||||||
|
const data = await res.json();
|
||||||
|
const list = (data.stakeholders || []).map((s: any) => `${s.name}(${s.role}) ${s.category}`).join('\n');
|
||||||
|
await replyMessage(messageId, list ? `👥 干系人(${pid}):\n${list}` : '暂无干系人数据');
|
||||||
|
} else if (lower.startsWith('/risks') || lower.startsWith('风险')) {
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
const pid = parts[1] || 'pmp-demo';
|
||||||
|
const res = await fetch(`http://localhost:3001/api/projects/${pid}/risks`);
|
||||||
|
const data = await res.json();
|
||||||
|
const list = (data.risks || []).map((r: any) => {
|
||||||
|
const emoji = r.level === 'critical' ? '🔴' : r.level === 'high' ? '🟠' : r.level === 'medium' ? '🟡' : '🟢';
|
||||||
|
return `${emoji} ${r.title}(${r.category}) 评分${r.riskScore}`;
|
||||||
|
}).join('\n');
|
||||||
|
await replyMessage(messageId, list ? `⚠️ 风险(${pid}):\n${list}` : '暂无风险');
|
||||||
|
} else if (lower.startsWith('/requirements') || lower.startsWith('需求')) {
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
const pid = parts[1] || 'pmp-demo';
|
||||||
|
const res = await fetch(`http://localhost:3001/api/projects/${pid}/requirements`);
|
||||||
|
const data = await res.json();
|
||||||
|
const list = (data.requirements || []).map((r: any) => {
|
||||||
|
const pMap: any = { must: '🔴M', should: '🟠S', could: '🔵C', wont: '⚪W' };
|
||||||
|
return `${pMap[r.priority] || '?'} ${r.title} [${r.status}]`;
|
||||||
|
}).join('\n');
|
||||||
|
await replyMessage(messageId, list ? `📝 需求(${pid}):\n${list}` : '暂无需求');
|
||||||
|
} else if (lower.startsWith('/changes') || lower.startsWith('变更')) {
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
const pid = parts[1] || 'pmp-demo';
|
||||||
|
const res = await fetch(`http://localhost:3001/api/projects/${pid}/changes`);
|
||||||
|
const data = await res.json();
|
||||||
|
const list = (data.changes || []).map((c: any) => `${c.title}(${c.type}) → ${c.status}`).join('\n');
|
||||||
|
await replyMessage(messageId, list ? `🔀 变更(${pid}):\n${list}` : '暂无变更');
|
||||||
|
} else if (lower.startsWith('/health') || lower === '健康度') {
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
const pid = parts[1] || 'pmp-demo';
|
||||||
|
const res = await fetch(`http://localhost:3001/api/projects/${pid}/health-reports`);
|
||||||
|
const data = await res.json();
|
||||||
|
const reports = data.reports || [];
|
||||||
|
if (reports.length === 0) {
|
||||||
|
await replyMessage(messageId, '暂无健康度报告,请先在前端生成');
|
||||||
|
} else {
|
||||||
|
const r = reports[0];
|
||||||
|
const emoji = r.overall === 'green' ? '🟢' : r.overall === 'yellow' ? '🟡' : '🔴';
|
||||||
|
const dims = r.dimensions.map((d: any) => `${d.name}:${d.score}`).join(' ');
|
||||||
|
await replyMessage(messageId, `${emoji} 健康度:${r.overallScore}分\n${dims}`);
|
||||||
|
}
|
||||||
|
} else if (lower.startsWith('/retro') || lower === '复盘') {
|
||||||
|
const parts = text.split(/\s+/);
|
||||||
|
const pid = parts[1] || 'pmp-demo';
|
||||||
|
const res = await fetch(`http://localhost:3001/api/projects/${pid}/retrospectives`);
|
||||||
|
const data = await res.json();
|
||||||
|
const retros = data.retrospectives || [];
|
||||||
|
if (retros.length === 0) {
|
||||||
|
await replyMessage(messageId, '暂无复盘数据');
|
||||||
|
} else {
|
||||||
|
const r = retros[0];
|
||||||
|
await replyMessage(messageId,
|
||||||
|
`🔄 复盘评分:${r.projectScore}分\n` +
|
||||||
|
`✅ 做得好:\n${r.wentWell.map((w: string) => ` · ${w}`).join('\n')}\n` +
|
||||||
|
`⚠️ 待改进:\n${r.toImprove.map((w: string) => ` · ${w}`).join('\n')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (lower === '/models' || lower === '模型') {
|
||||||
|
const res = await fetch('http://localhost:3001/api/models');
|
||||||
|
const models = await res.json();
|
||||||
|
const list = (Array.isArray(models) ? models : []).map((m: any) => `${m.enabled ? '✅' : '❌'} ${m.displayName}(${m.id})`).join('\n');
|
||||||
|
await replyMessage(messageId, `🤖 可用模型:\n${list}`);
|
||||||
|
} else {
|
||||||
|
await replyMessage(messageId, `收到:${text}\n输入 /help 查看可用指令。`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理卡片按钮回调
|
||||||
|
*/
|
||||||
|
async function handleCardAction(data: any): Promise<{ toast: { type: string; content: string } }> {
|
||||||
|
const action = data.action;
|
||||||
|
const openId = data.operator?.open_id || data.user?.open_id || '';
|
||||||
|
const messageId = data.context?.open_message_id || '';
|
||||||
|
|
||||||
|
if (!action?.value) {
|
||||||
|
return { toast: { type: 'info', content: '无效操作' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = action.value;
|
||||||
|
const decisionId = value.decisionId || '';
|
||||||
|
const actionKey = value.action || '';
|
||||||
|
|
||||||
|
console.log(`[Feishu WS] Card action: decisionId=${decisionId}, action=${actionKey}, user=${openId}`);
|
||||||
|
|
||||||
|
if (!decisionId) {
|
||||||
|
return { toast: { type: 'error', content: '无效的决策' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = handleDecisionCallback(decisionId, actionKey);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { toast: { type: 'error', content: result.error || '处理失败' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新卡片状态
|
||||||
|
const optionLabel = actionKey === 'approve' ? '✅ 已批准'
|
||||||
|
: actionKey === 'reject' ? '❌ 已驳回'
|
||||||
|
: `已选择:${actionKey}`;
|
||||||
|
|
||||||
|
const updatedCard = {
|
||||||
|
header: {
|
||||||
|
title: { tag: 'plain_text', content: `${result.record?.cardTitle || '决策'} - ${optionLabel}` },
|
||||||
|
template: actionKey === 'approve' ? 'green' : actionKey === 'reject' ? 'red' : 'blue',
|
||||||
|
},
|
||||||
|
elements: [
|
||||||
|
{ tag: 'div', text: { tag: 'plain_text', content: `决策结果:${optionLabel}\n操作人:${openId}\n时间:${new Date().toLocaleString('zh-CN')}` } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (messageId) {
|
||||||
|
await updateCard(messageId, updatedCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toast: { type: 'success', content: optionLabel } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动飞书长连接
|
||||||
|
*/
|
||||||
|
export async function startFeishuWS(): Promise<void> {
|
||||||
|
const eventDispatcher = new EventDispatcher({
|
||||||
|
verificationToken: FEISHU_VERIFICATION_TOKEN || undefined,
|
||||||
|
}).register({
|
||||||
|
// 接收消息
|
||||||
|
'im.message.receive_v1': handleIncomingMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wsClient = new WSClient({
|
||||||
|
appId: FEISHU_APP_ID,
|
||||||
|
appSecret: FEISHU_APP_SECRET,
|
||||||
|
domain: Domain.Feishu,
|
||||||
|
loggerLevel: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await wsClient.start({
|
||||||
|
eventDispatcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Feishu WebSocket 长连接已启动');
|
||||||
|
}
|
||||||
@@ -1,18 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* 飞书消息发送模块
|
* 飞书消息模块
|
||||||
* 支持两种方式:
|
*
|
||||||
* 1. 应用身份(推荐):使用 App ID/App Secret 获取 tenant_token 调用开放 API
|
* 功能:
|
||||||
* 2. Webhook 方式:直接调用自定义机器人 Webhook(向后兼容)
|
* 1. 消息发送(应用身份 + Webhook)
|
||||||
|
* 2. 事件回调处理(URL验证 + 签名校验 + 消息接收)
|
||||||
|
* 3. 卡片交互回调(按钮点击)
|
||||||
|
*
|
||||||
|
* 飞书事件订阅配置:
|
||||||
|
* - Request URL: https://your-domain/api/feishu/event
|
||||||
|
* - 加密策略:Verification Token + Encrypt Key(可选)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createHmac } from 'crypto';
|
import { createHmac, createHash } from 'crypto';
|
||||||
|
|
||||||
// 配置:从环境变量或 TOOLS.md 读取
|
// 配置:从环境变量读取
|
||||||
const FEISHU_APP_ID = process.env.FEISHU_APP_ID || 'cli_a95093447cb85cdd';
|
const FEISHU_APP_ID = process.env.FEISHU_APP_ID || 'cli_a95093447cb85cdd';
|
||||||
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET || 'd17CeffVfOnTkQo8LIP7hbhOQwSPv7Jv';
|
const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET || 'd17CeffVfOnTkQo8LIP7hbhOQwSPv7Jv';
|
||||||
const FEISHU_WEBHOOK = process.env.FEISHU_WEBHOOK || 'https://open.feishu.cn/open-apis/bot/v2/hook/58321c74-5881-4f41-bcd4-85f4d7c5b3c1';
|
const FEISHU_WEBHOOK = process.env.FEISHU_WEBHOOK || 'https://open.feishu.cn/open-apis/bot/v2/hook/58321c74-5881-4f41-bcd4-85f4d7c5b3c1';
|
||||||
const FEISHU_WEBHOOK_SECRET = process.env.FEISHU_WEBHOOK_SECRET || 'UgCdzrcci4s9YS1GSAHt4e';
|
const FEISHU_WEBHOOK_SECRET = process.env.FEISHU_WEBHOOK_SECRET || 'UgCdzrcci4s9YS1GSAHt4e';
|
||||||
|
|
||||||
|
/** 飞书事件订阅 Verification Token(在飞书开放平台 > 事件订阅中获取) */
|
||||||
|
const FEISHU_VERIFICATION_TOKEN = process.env.FEISHU_VERIFICATION_TOKEN || '';
|
||||||
|
/** 飞书事件订阅 Encrypt Key(如果启用了加密) */
|
||||||
|
const FEISHU_ENCRYPT_KEY = process.env.FEISHU_ENCRYPT_KEY || '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用应用身份获取 tenant_access_token
|
* 使用应用身份获取 tenant_access_token
|
||||||
*/
|
*/
|
||||||
@@ -185,12 +196,20 @@ export function buildDecisionCard(card: DecisionCardOptions): Record<string, unk
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送决策卡片
|
* 发送决策卡片(带 decisionId 用于回调追踪)
|
||||||
*/
|
*/
|
||||||
export async function sendDecisionCard(card: DecisionCardOptions, receiveId: string, receiveIdType: 'open_id' | 'user_id' = 'open_id'): Promise<{ ok: boolean; code: number; msg: string }> {
|
export async function sendDecisionCard(
|
||||||
|
card: DecisionCardOptions,
|
||||||
|
receiveId: string,
|
||||||
|
receiveIdType: 'open_id' | 'user_id' = 'open_id',
|
||||||
|
decisionId?: string,
|
||||||
|
): Promise<{ ok: boolean; code: number; msg: string }> {
|
||||||
const token = await getTenantToken();
|
const token = await getTenantToken();
|
||||||
const url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`;
|
const url = `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${receiveIdType}`;
|
||||||
|
|
||||||
|
// 决策 ID 嵌入按钮 value,回调时可定位
|
||||||
|
const id = decisionId || `decision-${Date.now()}`;
|
||||||
|
|
||||||
const cardContent = {
|
const cardContent = {
|
||||||
header: {
|
header: {
|
||||||
title: { tag: 'plain_text', content: card.title },
|
title: { tag: 'plain_text', content: card.title },
|
||||||
@@ -204,7 +223,7 @@ export async function sendDecisionCard(card: DecisionCardOptions, receiveId: str
|
|||||||
tag: 'button',
|
tag: 'button',
|
||||||
text: { tag: 'plain_text', content: opt.label },
|
text: { tag: 'plain_text', content: opt.label },
|
||||||
type: opt.style === 'danger' ? 'danger' : opt.style === 'primary' ? 'primary' : 'default',
|
type: opt.style === 'danger' ? 'danger' : opt.style === 'primary' ? 'primary' : 'default',
|
||||||
value: { action: opt.key },
|
value: { action: opt.key, decisionId: id },
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -229,3 +248,289 @@ export async function sendDecisionCard(card: DecisionCardOptions, receiveId: str
|
|||||||
msg: data.msg,
|
msg: data.msg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 事件回调 & 卡片回调
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞书事件回调请求体
|
||||||
|
*/
|
||||||
|
export interface FeishuEventRequest {
|
||||||
|
/** schema 版本 */
|
||||||
|
schema?: string;
|
||||||
|
/** 事件订阅头部 */
|
||||||
|
header?: {
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
create_time: string;
|
||||||
|
token: string;
|
||||||
|
app_id: string;
|
||||||
|
tenant_key: string;
|
||||||
|
};
|
||||||
|
/** 事件内容 */
|
||||||
|
event?: Record<string, any>;
|
||||||
|
/** URL 验证的 challenge */
|
||||||
|
challenge?: string;
|
||||||
|
token?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息接收事件体(im.message.receive_v1)
|
||||||
|
*/
|
||||||
|
export interface FeishuMessageEvent {
|
||||||
|
sender: {
|
||||||
|
sender_id: { union_id: string; user_id: string; open_id: string };
|
||||||
|
sender_type: string;
|
||||||
|
tenant_key: string;
|
||||||
|
};
|
||||||
|
message: {
|
||||||
|
message_id: string;
|
||||||
|
root_id: string;
|
||||||
|
parent_id: string;
|
||||||
|
create_time: string;
|
||||||
|
chat_id: string;
|
||||||
|
chat_type: 'p2p' | 'group';
|
||||||
|
message_type: string;
|
||||||
|
content: string;
|
||||||
|
mentions?: Array<{
|
||||||
|
key: string;
|
||||||
|
id: { union_id: string; user_id: string; open_id: string };
|
||||||
|
name: string;
|
||||||
|
tenant_key: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片交互回调请求体
|
||||||
|
*/
|
||||||
|
export interface FeishuCardActionRequest {
|
||||||
|
open_id: string;
|
||||||
|
open_message_id: string;
|
||||||
|
open_chat_id: string;
|
||||||
|
operator?: {
|
||||||
|
open_id: string;
|
||||||
|
user_id: string;
|
||||||
|
};
|
||||||
|
action?: {
|
||||||
|
value: Record<string, string>;
|
||||||
|
tag: string;
|
||||||
|
};
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息处理回调类型
|
||||||
|
*/
|
||||||
|
export type MessageHandler = (
|
||||||
|
event: FeishuMessageEvent,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片操作处理回调类型
|
||||||
|
*/
|
||||||
|
export type CardActionHandler = (
|
||||||
|
action: FeishuCardActionRequest,
|
||||||
|
) => { toast?: { type: 'success' | 'error' | 'info'; content: string } } | Promise<{ toast?: { type: 'success' | 'error' | 'info'; content: string } } | undefined> | undefined;
|
||||||
|
|
||||||
|
// 注册的处理器
|
||||||
|
const messageHandlers: MessageHandler[] = [];
|
||||||
|
const cardActionHandlers: CardActionHandler[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册消息处理器
|
||||||
|
*/
|
||||||
|
export function onMessage(handler: MessageHandler): void {
|
||||||
|
messageHandlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册卡片操作处理器
|
||||||
|
*/
|
||||||
|
export function onCardAction(handler: CardActionHandler): void {
|
||||||
|
cardActionHandlers.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理飞书事件回调(完整实现)
|
||||||
|
*
|
||||||
|
* 包含:
|
||||||
|
* 1. URL 验证(challenge)
|
||||||
|
* 2. Token 校验
|
||||||
|
* 3. 签名校验(使用 X-Lark-Signature / X-Lark-Request-Timestamp)
|
||||||
|
* 4. 消息接收事件分发
|
||||||
|
*/
|
||||||
|
export async function handleFeishuEvent(
|
||||||
|
body: FeishuEventRequest,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
): Promise<{ status: number; body: Record<string, any> }> {
|
||||||
|
// --- 1. URL 验证(配置事件订阅时飞书发来的 challenge 请求) ---
|
||||||
|
if (body.type === 'url_verification' || body.challenge) {
|
||||||
|
if (FEISHU_VERIFICATION_TOKEN && body.token !== FEISHU_VERIFICATION_TOKEN) {
|
||||||
|
return { status: 403, body: { error: 'Token mismatch' } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { challenge: body.challenge },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. 签名校验 ---
|
||||||
|
const timestamp = headers['x-lark-request-timestamp'] || headers['x-lark-request-fetch'] || '';
|
||||||
|
const nonce = headers['x-lark-request-nonce'] || '';
|
||||||
|
const signature = headers['x-lark-signature'] || '';
|
||||||
|
|
||||||
|
if (FEISHU_VERIFICATION_TOKEN && timestamp && signature) {
|
||||||
|
const expectedSig = genEventSignature(timestamp, nonce, FEISHU_VERIFICATION_TOKEN, JSON.stringify(body));
|
||||||
|
if (expectedSig !== signature) {
|
||||||
|
console.warn('[Feishu] Signature verification failed');
|
||||||
|
return { status: 403, body: { error: 'Invalid signature' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Token 校验 ---
|
||||||
|
if (FEISHU_VERIFICATION_TOKEN && body.header?.token && body.header.token !== FEISHU_VERIFICATION_TOKEN) {
|
||||||
|
console.warn('[Feishu] Token verification failed');
|
||||||
|
return { status: 403, body: { error: 'Invalid token' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. 分发事件 ---
|
||||||
|
const eventType = body.header?.event_type || body.type;
|
||||||
|
|
||||||
|
if (eventType === 'im.message.receive_v1' && body.event) {
|
||||||
|
const msgEvent = body.event as FeishuMessageEvent;
|
||||||
|
console.log(`[Feishu] Message received: type=${msgEvent.message?.message_type}, chat=${msgEvent.message?.chat_type}, from=${msgEvent.sender?.sender_id?.open_id}`);
|
||||||
|
|
||||||
|
for (const handler of messageHandlers) {
|
||||||
|
try {
|
||||||
|
await handler(msgEvent);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Feishu] Message handler error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 始终返回 200,避免飞书重试
|
||||||
|
return { status: 200, body: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成事件签名(飞书 v2 校验方式)
|
||||||
|
* 算法:sha256(timestamp + nonce + token + body)
|
||||||
|
*/
|
||||||
|
function genEventSignature(timestamp: string, nonce: string, token: string, bodyStr: string): string {
|
||||||
|
const content = `${timestamp}${nonce}${token}${bodyStr}`;
|
||||||
|
return createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理卡片交互回调
|
||||||
|
*
|
||||||
|
* 飞书卡片回调有两种方式:
|
||||||
|
* 1. HTTP 回调(配置 Request URL)— 推送到服务端
|
||||||
|
* 2. 通过事件订阅 card.action.trigger — 走事件通道
|
||||||
|
*
|
||||||
|
* 此函数处理方式 1(HTTP 回调),也兼容方式 2
|
||||||
|
*/
|
||||||
|
export async function handleCardCallback(
|
||||||
|
reqBody: FeishuCardActionRequest | { event?: Record<string, any>; header?: Record<string, any> },
|
||||||
|
): Promise<{ status: number; body: Record<string, any> }> {
|
||||||
|
let action: FeishuCardActionRequest;
|
||||||
|
|
||||||
|
// 事件订阅方式(card.action.trigger)
|
||||||
|
if ('event' in reqBody && reqBody.event && 'header' in reqBody) {
|
||||||
|
const eventData = reqBody.event as any;
|
||||||
|
action = {
|
||||||
|
open_id: eventData.operator?.open_id || eventData.user?.open_id || '',
|
||||||
|
open_message_id: eventData.context?.open_message_id || eventData.message_id || '',
|
||||||
|
open_chat_id: eventData.context?.open_chat_id || eventData.chat_id || '',
|
||||||
|
operator: eventData.operator || eventData.user,
|
||||||
|
action: eventData.action,
|
||||||
|
token: reqBody.header?.token,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
action = reqBody as FeishuCardActionRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action.action?.value) {
|
||||||
|
return { status: 200, body: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Feishu] Card action: user=${action.open_id}, value=${JSON.stringify(action.action.value)}`);
|
||||||
|
|
||||||
|
// 执行所有处理器,收集最后一个有返回值的 toast
|
||||||
|
let toastResult: { toast?: { type: 'success' | 'error' | 'info'; content: string } } | undefined;
|
||||||
|
for (const handler of cardActionHandlers) {
|
||||||
|
try {
|
||||||
|
const result = await handler(action);
|
||||||
|
if (result?.toast) toastResult = result;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Feishu] Card action handler error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 飞书卡片回调需要返回特定格式(含 toast 表示前端提示)
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: toastResult || { toast: { type: 'success' as const, content: '已收到' } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回复消息(通过 message_id 回复)
|
||||||
|
*/
|
||||||
|
export async function replyMessage(
|
||||||
|
messageId: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<{ ok: boolean; code: number; msg: string }> {
|
||||||
|
const token = await getTenantToken();
|
||||||
|
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}/reply`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
msg_type: 'text',
|
||||||
|
content: JSON.stringify({ text }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
ok: data.code === 0,
|
||||||
|
code: data.code,
|
||||||
|
msg: data.msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新卡片消息(根据 message_id 更新卡片内容)
|
||||||
|
*/
|
||||||
|
export async function updateCard(
|
||||||
|
messageId: string,
|
||||||
|
cardContent: Record<string, any>,
|
||||||
|
): Promise<{ ok: boolean; code: number; msg: string }> {
|
||||||
|
const token = await getTenantToken();
|
||||||
|
const url = `https://open.feishu.cn/open-apis/im/v1/messages/${messageId}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: JSON.stringify(cardContent),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
ok: data.code === 0,
|
||||||
|
code: data.code,
|
||||||
|
msg: data.msg,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,25 +55,5 @@ export function handleDecompose(highLevelTask: string, context?: Record<string,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export { executionApiHandlers };
|
||||||
* 飞书回调处理
|
|
||||||
*/
|
|
||||||
export function handleFeishuCallback(event: {
|
|
||||||
type: string;
|
|
||||||
event?: Record<string, unknown>;
|
|
||||||
}): { ok: boolean; message?: string } {
|
|
||||||
switch (event.type) {
|
|
||||||
case 'im.message.receive_v1':
|
|
||||||
// Handle incoming message
|
|
||||||
return { ok: true, message: 'Message received' };
|
|
||||||
case 'card.action.trigger':
|
|
||||||
// Handle card action (decision callback)
|
|
||||||
return { ok: true, message: 'Action processed' };
|
|
||||||
default:
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export for convenience
|
|
||||||
export { executionApiHandlers, sendFeishuMessage, notifyProjectCreated, notifyMilestoneReminder, notifyRiskAlert, sendDecisionCard, handleDecisionCallback, createDecision, getPendingDecisions, DECISION_TEMPLATES };
|
|
||||||
export type { DecisionRecord };
|
|
||||||
|
|||||||
@@ -2,11 +2,34 @@ import { Hono } from 'hono';
|
|||||||
import { cors } from 'hono/cors';
|
import { cors } from 'hono/cors';
|
||||||
import { logger } from 'hono/logger';
|
import { logger } from 'hono/logger';
|
||||||
import { executionApiHandlers } from './execution-api';
|
import { executionApiHandlers } from './execution-api';
|
||||||
import { handleDecompose, handleFeishuCallback } from './index';
|
import { handleDecompose } from './index';
|
||||||
import { sendFeishuMessage, notifyProjectCreated, notifyMilestoneReminder, notifyRiskAlert, sendDecisionCard } from './feishu';
|
import { notifyProjectCreated } from './feishu';
|
||||||
import { handleDecisionCallback, createDecision, getPendingDecisions, DECISION_TEMPLATES, DecisionRecord } from '../lib/decision-cards';
|
import { handleDecisionCallback, getPendingDecisions } from '../lib/decision-cards';
|
||||||
import { HRManager, AtomicTaskType } from '../lib/hr-manager';
|
import {
|
||||||
import { ExperienceManager } from '../lib/experience-manager';
|
createRequirement, getRequirements, getRequirement, updateRequirement, deleteRequirement, analyzeRequirement, detectConflicts as detectRequirementConflicts, generateCoverageReport, generateUserStory, sortByMoSCoW,
|
||||||
|
} from '../lib/requirement';
|
||||||
|
import {
|
||||||
|
createChangeRequest, getProjectChanges, getChange, approveChange, rejectChange, implementChange, updateChange, deleteChange, getChangeStats,
|
||||||
|
} from '../lib/change';
|
||||||
|
import {
|
||||||
|
generateHealthReport, getProjectReports, getReport,
|
||||||
|
} from '../lib/health-report';
|
||||||
|
import {
|
||||||
|
generateRetrospective, addKnowledge, getProjectKnowledge, getKnowledge, searchKnowledge, getProjectRetrospectives, getRetrospective,
|
||||||
|
} from '../lib/retrospective';
|
||||||
|
import {
|
||||||
|
onMessage,
|
||||||
|
onCardAction,
|
||||||
|
handleFeishuEvent,
|
||||||
|
handleCardCallback,
|
||||||
|
replyMessage,
|
||||||
|
updateCard,
|
||||||
|
FeishuMessageEvent,
|
||||||
|
FeishuCardActionRequest,
|
||||||
|
} from './feishu';
|
||||||
|
import { createStakeholder, getProjectStakeholders, getStakeholder, updateStakeholder, deleteStakeholder, generateStakeholderAnalysis, recommendStrategy } from '../lib/stakeholder';
|
||||||
|
import { createWBSNode, getProjectWBS, getWBSNode, updateWBSNode, deleteWBSNode, buildWBSTree, decomposeTask } from '../lib/wbs';
|
||||||
|
import { createRisk, getRisksByProject, getRisk, updateRisk, deleteRisk, identifyRisks, recommendResponse, generateRiskMatrix } from '../lib/risk';
|
||||||
|
|
||||||
// In-memory stores
|
// In-memory stores
|
||||||
const projects: Record<string, any> = {};
|
const projects: Record<string, any> = {};
|
||||||
@@ -114,20 +137,506 @@ app.post('/api/projects/:id/decisions', async (c) => {
|
|||||||
return c.json(record, 201);
|
return c.json(record, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Feishu webhook
|
// ============================================================
|
||||||
app.post('/api/feishu/webhook', async (c) => {
|
// 飞书消息处理:收到用户消息后的响应逻辑
|
||||||
const event = await c.req.json();
|
// ============================================================
|
||||||
const result = handleFeishuCallback(event);
|
|
||||||
return c.json(result);
|
onMessage(async (event: FeishuMessageEvent) => {
|
||||||
|
const { message, sender } = event;
|
||||||
|
const msgType = message.message_type;
|
||||||
|
const chatType = message.chat_type;
|
||||||
|
const openId = sender.sender_id.open_id;
|
||||||
|
|
||||||
|
// 只处理文本消息
|
||||||
|
if (msgType !== 'text') {
|
||||||
|
await replyMessage(message.message_id, '暂只支持文本消息,敬请谅解 🙏');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析文本内容
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(message.content);
|
||||||
|
text = (parsed.text || '').trim();
|
||||||
|
} catch {
|
||||||
|
text = message.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Feishu] Text message: "${text}" from ${openId} (${chatType})`);
|
||||||
|
|
||||||
|
// 简易指令处理
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
|
||||||
|
if (lower === '/help' || lower === '帮助') {
|
||||||
|
await replyMessage(message.message_id,
|
||||||
|
'📋 FlowPilot 指令列表:\n' +
|
||||||
|
'• /help - 显示帮助\n' +
|
||||||
|
'• /status - 查看项目状态\n' +
|
||||||
|
'• /decisions - 查看待决策\n' +
|
||||||
|
'• /tasks - 查看任务列表\n' +
|
||||||
|
'\n也可以直接发消息,我会尝试处理。'
|
||||||
|
);
|
||||||
|
} else if (lower === '/status' || lower === '状态') {
|
||||||
|
const projectCount = Object.keys(projects).length;
|
||||||
|
await replyMessage(message.message_id,
|
||||||
|
`📊 FlowPilot 状态:\n` +
|
||||||
|
`• 项目数:${projectCount}\n` +
|
||||||
|
`• 版本:v0.5.0\n` +
|
||||||
|
`• 状态:运行中 ✅`
|
||||||
|
);
|
||||||
|
} else if (lower === '/decisions' || lower === '决策') {
|
||||||
|
const pending = getPendingDecisions();
|
||||||
|
if (pending.length === 0) {
|
||||||
|
await replyMessage(message.message_id, '当前没有待决策项 ✅');
|
||||||
|
} else {
|
||||||
|
const list = pending.map((d, i) => `${i + 1}. ${d.cardTitle}(${d.type})`).join('\n');
|
||||||
|
await replyMessage(message.message_id, `⏳ 待决策项:\n${list}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 默认回复
|
||||||
|
await replyMessage(message.message_id, `收到:${text}\n输入 /help 查看可用指令。`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Feishu card callback
|
// ============================================================
|
||||||
|
// 飞书卡片按钮回调:处理决策按钮点击
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
onCardAction(async (action: FeishuCardActionRequest) => {
|
||||||
|
const value = action.action!.value;
|
||||||
|
const decisionId = value.decisionId || '';
|
||||||
|
const actionKey = value.action || '';
|
||||||
|
|
||||||
|
console.log(`[Feishu] Card button clicked: decisionId=${decisionId}, action=${actionKey}, user=${action.open_id}`);
|
||||||
|
|
||||||
|
if (!decisionId) {
|
||||||
|
return { toast: { type: 'error' as const, content: '无效的决策' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理决策
|
||||||
|
const result = handleDecisionCallback(decisionId, actionKey);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { toast: { type: 'error' as const, content: result.error || '处理失败' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新卡片显示已选择的状态
|
||||||
|
const optionLabel = actionKey === 'approve' ? '✅ 已批准'
|
||||||
|
: actionKey === 'reject' ? '❌ 已驳回'
|
||||||
|
: `已选择:${actionKey}`;
|
||||||
|
|
||||||
|
const updatedCard = {
|
||||||
|
header: {
|
||||||
|
title: { tag: 'plain_text', content: `${result.record?.cardTitle || '决策'} - ${optionLabel}` },
|
||||||
|
template: actionKey === 'approve' ? 'green' : actionKey === 'reject' ? 'red' : 'blue',
|
||||||
|
},
|
||||||
|
elements: [
|
||||||
|
{ tag: 'div', text: { tag: 'plain_text', content: `决策结果:${optionLabel}\n操作人:${action.open_id}\n时间:${new Date().toLocaleString('zh-CN')}` } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateCard(action.open_message_id, updatedCard);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast: {
|
||||||
|
type: 'success' as const,
|
||||||
|
content: optionLabel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 路由注册
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 飞书事件订阅回调(消息接收、URL 验证等)
|
||||||
|
app.post('/api/feishu/event', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
// 提取飞书签名相关 header
|
||||||
|
const larkHeaders = ['x-lark-signature', 'x-lark-request-timestamp', 'x-lark-request-nonce'];
|
||||||
|
for (const h of larkHeaders) {
|
||||||
|
const v = c.req.header(h);
|
||||||
|
if (v) headers[h] = v;
|
||||||
|
}
|
||||||
|
const result = await handleFeishuEvent(body, headers);
|
||||||
|
return c.json(result.body, result.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保留旧路由兼容(转发到新处理)
|
||||||
|
app.post('/api/feishu/webhook', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const larkHeaders = ['x-lark-signature', 'x-lark-request-timestamp', 'x-lark-request-nonce'];
|
||||||
|
for (const h of larkHeaders) {
|
||||||
|
const v = c.req.header(h);
|
||||||
|
if (v) headers[h] = v;
|
||||||
|
}
|
||||||
|
const result = await handleFeishuEvent(body, headers);
|
||||||
|
return c.json(result.body, result.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 飞书卡片交互回调(按钮点击)
|
||||||
|
app.post('/api/feishu/card', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const result = await handleCardCallback(body);
|
||||||
|
return c.json(result.body, result.status as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保留旧路由兼容
|
||||||
app.post('/api/feishu/decision/callback', async (c) => {
|
app.post('/api/feishu/decision/callback', async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
// Expecting: { action: string, value: { ... } }
|
const result = await handleCardCallback(body);
|
||||||
const { action, value } = body;
|
return c.json(result.body, result.status as any);
|
||||||
// TODO: handle decision callback, update decision log, etc.
|
});
|
||||||
return c.json({ ok: true });
|
|
||||||
|
// ============================================================
|
||||||
|
// 多模型路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllModels, getModel, updateModel, selectModel, recordCall, getCostStats, compareModels,
|
||||||
|
} from '../lib/multi-model';
|
||||||
|
|
||||||
|
app.get('/api/models', (c) => c.json(getAllModels()));
|
||||||
|
|
||||||
|
app.get('/api/models/:modelId', (c) => {
|
||||||
|
const model = getModel(c.req.param('modelId'));
|
||||||
|
return model ? c.json(model) : c.json({ error: 'Model not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/models/:modelId', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const updated = updateModel(c.req.param('modelId'), body);
|
||||||
|
return updated ? c.json(updated) : c.json({ error: 'Model not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/models/select', async (c) => {
|
||||||
|
const { taskType, strategy } = await c.req.json();
|
||||||
|
try {
|
||||||
|
const model = selectModel(taskType, strategy);
|
||||||
|
return c.json(model);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ error: e.message }, 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/model-costs', (c) => {
|
||||||
|
const from = c.req.query('from');
|
||||||
|
const to = c.req.query('to');
|
||||||
|
const period = (from && to) ? { from, to } : undefined;
|
||||||
|
return c.json(getCostStats(c.req.param('id'), period));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/model-comparison', (c) => c.json(compareModels()));
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/model-calls', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const entry = recordCall({ ...body, projectId: c.req.param('id') });
|
||||||
|
return c.json(entry, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 干系人路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/stakeholders', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const s = createStakeholder({ ...body, projectId: c.req.param('id') });
|
||||||
|
return c.json(s, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/stakeholders', (c) => {
|
||||||
|
const list = getProjectStakeholders(c.req.param('id'));
|
||||||
|
return c.json({ stakeholders: list });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/stakeholders/:sid', (c) => {
|
||||||
|
const s = getStakeholder(c.req.param('sid'));
|
||||||
|
return s ? c.json(s) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:id/stakeholders/:sid', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const updated = updateStakeholder(c.req.param('sid'), body);
|
||||||
|
return updated ? c.json(updated) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/stakeholders/:sid', (c) => {
|
||||||
|
const ok = deleteStakeholder(c.req.param('sid'));
|
||||||
|
return ok ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/stakeholders/analyze', (c) => {
|
||||||
|
const list = getProjectStakeholders(c.req.param('id'));
|
||||||
|
return c.json(generateStakeholderAnalysis(list));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/stakeholders/:sid/strategy', (c) => {
|
||||||
|
const s = getStakeholder(c.req.param('sid'));
|
||||||
|
if (!s) return c.json({ error: 'Not found' }, 404);
|
||||||
|
return c.json(recommendStrategy(s.category, s.role));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// WBS 路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/wbs', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const node = createWBSNode({ ...body, projectId: c.req.param('id') });
|
||||||
|
return c.json(node, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/wbs', (c) => {
|
||||||
|
const nodes = getProjectWBS(c.req.param('id'));
|
||||||
|
return c.json({ tree: buildWBSTree(nodes), total: nodes.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/wbs/:nodeId', (c) => {
|
||||||
|
const node = getWBSNode(c.req.param('nodeId'));
|
||||||
|
return node ? c.json(node) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:id/wbs/:nodeId', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const updated = updateWBSNode(c.req.param('nodeId'), body);
|
||||||
|
return updated ? c.json(updated) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/wbs/:nodeId', (c) => {
|
||||||
|
const count = deleteWBSNode(c.req.param('nodeId'));
|
||||||
|
return c.json({ ok: true, deletedCount: count });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/wbs/:nodeId/decompose', async (c) => {
|
||||||
|
const parent = getWBSNode(c.req.param('nodeId'));
|
||||||
|
if (!parent) return c.json({ error: 'Not found' }, 404);
|
||||||
|
const children = decomposeTask({ name: parent.name, description: parent.description }, parent.level, parent.wbsCode, parent.projectId);
|
||||||
|
// 设置 parentId
|
||||||
|
const saved = children.map((child, _i) => {
|
||||||
|
return createWBSNode({ ...child, parentId: parent.id });
|
||||||
|
});
|
||||||
|
return c.json({ parent: parent.id, children: saved });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 风险路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/risks', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const r = createRisk(c.req.param("id"), body);
|
||||||
|
return c.json(r, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/risks', (c) => {
|
||||||
|
const level = c.req.query('level');
|
||||||
|
const status = c.req.query('status');
|
||||||
|
let list = getRisksByProject(c.req.param('id'));
|
||||||
|
if (level) list = list.filter((r: any) => r.level === level);
|
||||||
|
if (status) list = list.filter((r: any) => r.status === status);
|
||||||
|
return c.json({ risks: list });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/risks/:rid', (c) => {
|
||||||
|
const r = getRisk(c.req.param('rid'));
|
||||||
|
return r ? c.json(r) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:id/risks/:rid', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const updated = updateRisk(c.req.param('rid'), body);
|
||||||
|
return updated ? c.json(updated) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/risks/:rid', (c) => {
|
||||||
|
const ok = deleteRisk(c.req.param('rid'));
|
||||||
|
return ok ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/risks/identify', async (c) => {
|
||||||
|
const { description, type } = await c.req.json();
|
||||||
|
const risks = identifyRisks(description || '', type);
|
||||||
|
return c.json({ risks });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/risks/:rid/respond', (c) => {
|
||||||
|
const r = getRisk(c.req.param('rid'));
|
||||||
|
if (!r) return c.json({ error: 'Not found' }, 404);
|
||||||
|
return c.json(recommendResponse(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/risks/matrix', (c) => {
|
||||||
|
const list = getRisksByProject(c.req.param('id'));
|
||||||
|
return c.json(generateRiskMatrix(list));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 需求路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/requirements', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const r = createRequirement(c.req.param("id"), body);
|
||||||
|
return c.json(r, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/requirements', (c) => {
|
||||||
|
const priority = c.req.query('priority') as any;
|
||||||
|
const status = c.req.query('status') as any;
|
||||||
|
const category = c.req.query('category') as any;
|
||||||
|
let list = getRequirements(c.req.param('id'));
|
||||||
|
if (priority) list = list.filter((r: any) => r.priority === priority);
|
||||||
|
if (status) list = list.filter((r: any) => r.status === status);
|
||||||
|
if (category) list = list.filter((r: any) => r.category === category);
|
||||||
|
return c.json({ requirements: sortByMoSCoW(list) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/requirements/:rid', (c) => {
|
||||||
|
const r = getRequirement(c.req.param('rid'));
|
||||||
|
return r ? c.json(r) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:id/requirements/:rid', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const updated = updateRequirement(c.req.param('rid'), body);
|
||||||
|
return updated ? c.json(updated) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/requirements/:rid', (c) => {
|
||||||
|
const ok = deleteRequirement(c.req.param('rid'));
|
||||||
|
return ok ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/requirements/analyze', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
return c.json(analyzeRequirement(body));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/requirements/detect-conflicts', (c) => {
|
||||||
|
const list = getRequirements(c.req.param('id'));
|
||||||
|
return c.json({ conflicts: detectRequirementConflicts(list) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/requirements/coverage', (c) => {
|
||||||
|
const list = getRequirements(c.req.param('id'));
|
||||||
|
return c.json(generateCoverageReport(list));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/requirements/:rid/user-story', async (c) => {
|
||||||
|
const r = getRequirement(c.req.param('rid'));
|
||||||
|
if (!r) return c.json({ error: 'Not found' }, 404);
|
||||||
|
const story = generateUserStory(r.description);
|
||||||
|
const updated = updateRequirement(c.req.param('rid'), { userStory: story } as any);
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 变更管理路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/changes', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const cr = createChangeRequest(c.req.param('id'), body);
|
||||||
|
return c.json(cr, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/changes', (c) => {
|
||||||
|
const type = c.req.query('type') as any;
|
||||||
|
const status = c.req.query('status') as any;
|
||||||
|
return c.json({ changes: getProjectChanges(c.req.param('id'), { type, status }) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/changes/stats', (c) => c.json(getChangeStats(c.req.param('id'))));
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/changes/:cid', (c) => {
|
||||||
|
const cr = getChange(c.req.param('cid'));
|
||||||
|
return cr ? c.json(cr) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/projects/:id/changes/:cid', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const updated = updateChange(c.req.param('cid'), body);
|
||||||
|
return updated ? c.json(updated) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/projects/:id/changes/:cid', (c) => {
|
||||||
|
return deleteChange(c.req.param('cid')) ? c.json({ ok: true }) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/changes/:cid/approve', async (c) => {
|
||||||
|
const { approver } = await c.req.json();
|
||||||
|
const cr = approveChange(c.req.param('cid'), approver);
|
||||||
|
return cr ? c.json(cr) : c.json({ error: 'Cannot approve' }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/changes/:cid/reject', async (c) => {
|
||||||
|
const { reason } = await c.req.json();
|
||||||
|
const cr = rejectChange(c.req.param('cid'), reason || '');
|
||||||
|
return cr ? c.json(cr) : c.json({ error: 'Cannot reject' }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/changes/:cid/implement', async (c) => {
|
||||||
|
const { notes } = await c.req.json();
|
||||||
|
const cr = implementChange(c.req.param('cid'), notes || '');
|
||||||
|
return cr ? c.json(cr) : c.json({ error: 'Cannot implement' }, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 健康度报告路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/health-report', async (c) => {
|
||||||
|
const stats = await c.req.json();
|
||||||
|
const report = generateHealthReport(c.req.param('id'), stats);
|
||||||
|
return c.json(report, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/health-reports', (c) => c.json({ reports: getProjectReports(c.req.param('id')) }));
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/health-reports/:rid', (c) => {
|
||||||
|
const r = getReport(c.req.param('rid'));
|
||||||
|
return r ? c.json(r) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 复盘与知识库路由
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/retrospective', async (c) => {
|
||||||
|
const projectData = await c.req.json();
|
||||||
|
const retro = generateRetrospective(c.req.param('id'), projectData);
|
||||||
|
return c.json(retro, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/retrospectives', (c) => c.json({ retrospectives: getProjectRetrospectives(c.req.param('id')) }));
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/retrospectives/:rid', (c) => {
|
||||||
|
const r = getRetrospective(c.req.param('rid'));
|
||||||
|
return r ? c.json(r) : c.json({ error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/projects/:id/knowledge', async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const entry = addKnowledge({ ...body, projectId: c.req.param('id') });
|
||||||
|
return c.json(entry, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/projects/:id/knowledge', (c) => {
|
||||||
|
const type = c.req.query('type') as any;
|
||||||
|
return c.json({ knowledge: getProjectKnowledge(c.req.param('id'), type) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/knowledge/search', (c) => {
|
||||||
|
const q = c.req.query('q') || '';
|
||||||
|
const limit = Number(c.req.query('limit')) || 10;
|
||||||
|
return c.json({ results: searchKnowledge(q, limit) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/knowledge/:kid', (c) => {
|
||||||
|
const e = getKnowledge(c.req.param('kid'));
|
||||||
|
return e ? c.json(e) : c.json({ error: 'Not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = Number(process.env.PORT) || 3001;
|
const port = Number(process.env.PORT) || 3001;
|
||||||
|
|||||||
Reference in New Issue
Block a user