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
374 lines
20 KiB
TypeScript
374 lines
20 KiB
TypeScript
// Dev server entry - runs Hono on Node.js
|
||
import { Hono } from 'hono';
|
||
import { cors } from 'hono/cors';
|
||
import { logger } from 'hono/logger';
|
||
import { createServer } from 'http';
|
||
import { serve } from '@hono/node-server';
|
||
import { executionApiHandlers } from './execution-api';
|
||
import { handleDecompose } from './index';
|
||
import {
|
||
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 app = new Hono();
|
||
|
||
app.use('*', cors());
|
||
app.use('*', logger());
|
||
|
||
app.get('/api/health', (c) => c.json({ status: 'ok', version: '0.5.0', message: 'FlowPilot API is running' }));
|
||
|
||
// Create project + send Feishu notification
|
||
app.post('/api/projects', async (c) => {
|
||
const body = await c.req.json();
|
||
const projectId = `proj-${Date.now()}`;
|
||
const project = { id: projectId, ...body, status: 'active', createdAt: new Date().toISOString() };
|
||
projects[projectId] = project;
|
||
|
||
try {
|
||
await notifyProjectCreated(project.name || '未命名项目', project.goal || '', 'ou_41d14aca8278e605d98e33b1221777e4', 'open_id');
|
||
console.log('✅ Feishu notification sent for project:', project.name);
|
||
} catch (e: any) {
|
||
console.error('❌ Feishu notification failed:', e.message);
|
||
}
|
||
|
||
return c.json(project);
|
||
});
|
||
|
||
app.get('/api/projects/:id', (c) => c.json(projects[c.req.param('id')] || { error: 'not found' }));
|
||
app.get('/api/projects/:id/tasks', (c) => c.json({ tasks: [], projectId: c.req.param('id') }));
|
||
|
||
app.post('/api/projects/:id/tasks', async (c) => {
|
||
const body = await c.req.json();
|
||
return c.json({ id: `task-${Date.now()}`, projectId: c.req.param('id'), ...body, status: 'todo', createdAt: new Date().toISOString() });
|
||
});
|
||
|
||
app.patch('/api/projects/:id/tasks/:taskId', async (c) => {
|
||
const body = await c.req.json();
|
||
return c.json({ id: c.req.param('taskId'), ...body, updatedAt: new Date().toISOString() });
|
||
});
|
||
|
||
app.get('/api/projects/:id/executions', (c) => c.json({ executions: executionApiHandlers.getExecutions(c.req.param('id')) }));
|
||
app.post('/api/projects/:id/executions', async (c) => { const b = await c.req.json(); return c.json(executionApiHandlers.createExecution(c.req.param('id'), b), 201); });
|
||
app.get('/api/projects/:id/stats', (c) => c.json(executionApiHandlers.getStats(c.req.param('id'))));
|
||
|
||
app.post('/api/projects/:id/decompose', async (c) => {
|
||
const b = await c.req.json();
|
||
return c.json(handleDecompose(b.task, b.context));
|
||
});
|
||
|
||
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); });
|
||
|
||
// ============================================================
|
||
// 飞书消息处理
|
||
// ============================================================
|
||
|
||
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));
|
||
|
||
const port = Number(process.env.PORT) || 3001;
|
||
serve({ fetch: app.fetch, port }, () => {
|
||
console.log(`🚀 FlowPilot API running on http://localhost:${port}`);
|
||
});
|