feat: P1 full implementation - 8 modules + frontend pages + feishu WS
Some checks failed
CI / lint-and-typecheck (push) Failing after 42s
CI / test (push) Has been skipped
CI / build (push) Has been skipped

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:
xiaohei
2026-04-12 18:51:41 +08:00
parent ab0154bcf9
commit 2532cf4f4e
24 changed files with 4157 additions and 68 deletions

27
P1-PLAN.md Normal file
View 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 依赖 WBSP1-2P1-6 依赖 WBS+风险P1-7 依赖报告
- P1-8 独立,随时可做
## 技术规范
- 数据层:先用内存 Map/JSON和 P0 一致),后续再迁 DB
- APIRESTHono 路由,挂载到 main.ts
- 飞书通知:复用现有 feishu.ts 的发送能力
- 前端暂不做P1 先出后端 API + 飞书卡片交互

View File

@@ -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)接收消息 | ✅ |
| - | 飞书卡片按钮回调 | ✅ |
## 项目结构 ## 项目结构
``` ```

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,213 @@
/**
* WBSWork 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
import React from 'react';
import KanbanBoard from '../components/KanbanBoard';
const KanbanPage: React.FC = () => {
return <KanbanBoard />;
};
export default KanbanPage;

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

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

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

View File

@@ -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
View 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 长连接已启动');
}

View File

@@ -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 — 走事件通道
*
* 此函数处理方式 1HTTP 回调),也兼容方式 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,
};
}

View File

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

View File

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