/** * 星环OPC中心 — Dashboard 监控中心 API * * 路由: * GET /opc/admin/api/dashboard — 获取监控中心数据 * POST /opc/admin/api/alerts/:id/dismiss — 忽略预警 */ import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import type { OpcDatabase } from "../db/index.js"; import { generateDashboardHtml, type DashboardData, type DashboardMetrics, type DashboardAlert, type DashboardTodo, type DashboardSuggestion, } from "../web/dashboard-ui.js"; interface DashboardRow { total_income: number; total_expense: number; } interface PaymentRow { id: string; company_id: string; direction: string; counterparty: string; amount: number; paid_amount: number; status: string; due_date: string; category: string; } interface AlertRow { id: string; company_id: string; title: string; severity: string; category: string; status: string; message: string; created_at: string; } interface TodoRow { id: string; company_id: string; title: string; priority: string; category: string; status: string; due_date: string; description: string; } /** * 注册 Dashboard API 路由 */ export function registerDashboardApiRoutes(api: OpenClawPluginApi, db: OpcDatabase): void { const handler = async (req: IncomingMessage, res: ServerResponse): Promise => { const rawUrl = req.url ?? ""; const urlObj = new URL(rawUrl, "http://localhost"); const pathname = urlObj.pathname; const method = req.method?.toUpperCase() ?? "GET"; // GET /opc/admin/api/dashboard if (pathname === "/opc/admin/api/dashboard" && method === "GET") { try { const data = getDashboardData(db); const html = generateDashboardHtml(data); res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ...data, html })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); } return true; } // POST /opc/admin/api/alerts/:id/dismiss const alertMatch = pathname.match(/^\/opc\/admin\/api\/alerts\/([^/]+)\/dismiss$/); if (alertMatch && method === "POST") { try { const alertId = alertMatch[1]; if (!alertId) { res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "Missing alert ID" })); return true; } const now = new Date().toISOString(); const result = db.execute( "UPDATE opc_alerts SET status = 'resolved', resolved_at = ? WHERE id = ? AND status = 'active'", now, alertId, ); if (result.changes > 0) { res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ ok: true })); } else { res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "Alert not found or already resolved" })); } } catch (err) { res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) })); } return true; } return false; }; const apiAny = api as unknown as { registerHttpHandler?: (h: (req: IncomingMessage, res: ServerResponse) => Promise | boolean) => void; registerHttpRoute?: (r: { path: string; handler: (req: IncomingMessage, res: ServerResponse) => Promise | boolean | void; auth: string; match?: string }) => void; }; if (typeof apiAny.registerHttpHandler === "function") { apiAny.registerHttpHandler(handler); } else if (typeof apiAny.registerHttpRoute === "function") { apiAny.registerHttpRoute({ path: "/opc/admin/api", handler, auth: "plugin", match: "prefix", }); } } /** * 获取 Dashboard 数据 */ function getDashboardData(db: OpcDatabase): DashboardData { const metrics = calculateMetrics(db); const alerts = getActiveAlerts(db); const todos = getTodayTodos(db); const suggestions = generateSuggestions(db, metrics, alerts, todos); return { metrics, alerts, todos, suggestions, }; } /** * 计算关键指标 */ function calculateMetrics(db: OpcDatabase): DashboardMetrics { // 本月收入和支出 const now = new Date(); const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); const currentMonthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10); const currentMonth = db.queryOne( `SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) as total_income, COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) as total_expense FROM opc_transactions WHERE transaction_date >= ? AND transaction_date <= ?`, currentMonthStart, currentMonthEnd, ) as DashboardRow; // 上月收入和支出(用于计算同比) const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10); const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10); const lastMonth = db.queryOne( `SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) as total_income, COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) as total_expense FROM opc_transactions WHERE transaction_date >= ? AND transaction_date <= ?`, lastMonthStart, lastMonthEnd, ) as DashboardRow; const monthlyIncome = currentMonth.total_income; const monthlyExpense = currentMonth.total_expense; const monthlyProfit = monthlyIncome - monthlyExpense; // 计算同比变化(避免除零) const monthlyIncomeChange = lastMonth.total_income > 0 ? ((monthlyIncome - lastMonth.total_income) / lastMonth.total_income) * 100 : 0; const lastMonthProfit = lastMonth.total_income - lastMonth.total_expense; const monthlyProfitChange = lastMonthProfit !== 0 ? ((monthlyProfit - lastMonthProfit) / Math.abs(lastMonthProfit)) * 100 : 0; // 现金余额(所有收入 - 所有支出) const cashBalance = db.queryOne( `SELECT COALESCE(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN type='expense' THEN amount ELSE 0 END), 0) as balance FROM opc_transactions`, ) as { balance: number }; // 可撑月数(基于最近3个月平均支出) const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, 1).toISOString().slice(0, 10); const avgExpense = db.queryOne( `SELECT COALESCE(AVG(monthly_expense), 0) as avg_expense FROM ( SELECT SUM(amount) as monthly_expense FROM opc_transactions WHERE type='expense' AND transaction_date >= ? GROUP BY strftime('%Y-%m', transaction_date) )`, threeMonthsAgo, ) as { avg_expense: number }; const monthsOfRunway = avgExpense.avg_expense > 0 ? cashBalance.balance / avgExpense.avg_expense : 999; // 应收账款(未收和部分已收的应收款) const receivables = db.queryOne( `SELECT COALESCE(SUM(amount - paid_amount), 0) as total FROM opc_payments WHERE direction = 'receivable' AND status IN ('pending', 'partial')`, ) as { total: number }; // 逾期应收账款 const today = new Date().toISOString().slice(0, 10); const overdueReceivables = db.queryOne( `SELECT COALESCE(SUM(amount - paid_amount), 0) as total FROM opc_payments WHERE direction = 'receivable' AND status IN ('pending', 'partial', 'overdue') AND due_date < ?`, today, ) as { total: number }; return { monthlyIncome, monthlyIncomeChange, monthlyProfit, monthlyProfitChange, cashBalance: cashBalance.balance, monthsOfRunway, receivables: receivables.total, overdueReceivables: overdueReceivables.total, }; } /** * 获取活跃预警 */ function getActiveAlerts(db: OpcDatabase): DashboardAlert[] { const alerts = db.query( `SELECT id, company_id, title, severity, category, message, created_at FROM opc_alerts WHERE status = 'active' ORDER BY CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, created_at DESC LIMIT 10`, ) as AlertRow[]; return alerts.map(alert => ({ id: alert.id, title: alert.title, severity: alert.severity as 'critical' | 'warning' | 'info', category: alert.category, message: alert.message, created_at: alert.created_at, })); } /** * 获取今日待办 */ function getTodayTodos(db: OpcDatabase): DashboardTodo[] { const today = new Date().toISOString().slice(0, 10); const todos = db.query( `SELECT id, company_id, title, priority, related_type as category, due_date, description FROM opc_todos WHERE status = 'pending' AND (due_date = ? OR due_date < ?) ORDER BY CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 ELSE 2 END, due_date ASC LIMIT 20`, today, today, ) as TodoRow[]; return todos.map(todo => ({ id: todo.id, title: todo.title, priority: todo.priority as 'urgent' | 'high' | 'normal', category: todo.category || '', due_date: todo.due_date || undefined, description: todo.description || undefined, })); } /** * 生成 AI 建议 */ function generateSuggestions( db: OpcDatabase, metrics: DashboardMetrics, alerts: DashboardAlert[], todos: DashboardTodo[], ): DashboardSuggestion[] { const suggestions: DashboardSuggestion[] = []; // 建议1: 现金流预警 if (metrics.monthsOfRunway < 2) { suggestions.push({ id: "cash-runway-low", title: "现金流预警:可撑月数不足", description: `当前现金余额 ¥${metrics.cashBalance.toFixed(0)} 仅能维持 ${metrics.monthsOfRunway.toFixed(1)} 个月运营。建议尽快催收应收账款或拓展收入来源。`, action: { label: "查看应收账款", onclick: "loadView('finance')", }, }); } // 建议2: 逾期催收 if (metrics.overdueReceivables > 0) { suggestions.push({ id: "overdue-receivables", title: "逾期账款催收提醒", description: `有 ¥${metrics.overdueReceivables.toFixed(0)} 的应收账款已逾期,建议立即联系客户催收。`, action: { label: "查看逾期清单", onclick: "loadView('finance')", }, }); } // 建议3: 利润优化 if (metrics.monthlyProfit < 0) { suggestions.push({ id: "negative-profit", title: "本月利润为负,需优化成本", description: `本月支出超过收入 ¥${Math.abs(metrics.monthlyProfit).toFixed(0)},建议检查支出明细,削减非必要成本。`, action: { label: "查看支出分析", onclick: "loadView('finance')", }, }); } // 建议4: 收入增长策略 if (metrics.monthlyIncomeChange < -10) { suggestions.push({ id: "income-decline", title: "收入下滑,需拓展业务", description: `本月收入较上月下降 ${Math.abs(metrics.monthlyIncomeChange).toFixed(1)}%,建议加强客户开发和老客户维护。`, action: { label: "查看客户管理", onclick: "loadView('companies')", }, }); } // 建议5: 合同到期提醒 const expiringContracts = db.query( `SELECT COUNT(*) as cnt FROM opc_contracts WHERE status = 'active' AND end_date <= date('now', '+30 days') AND end_date >= date('now')`, ) as { cnt: number }[]; if (expiringContracts.length > 0 && expiringContracts[0].cnt > 0) { suggestions.push({ id: "contracts-expiring", title: "合同即将到期", description: `有 ${expiringContracts[0].cnt} 份合同将在 30 天内到期,建议提前联系续约。`, action: { label: "查看合同列表", onclick: "loadView('companies')", }, }); } // 默认建议:一切正常 if (suggestions.length === 0) { suggestions.push({ id: "all-good", title: "经营状况良好", description: "当前各项指标正常,继续保持!建议定期回顾财务数据,优化运营效率。", }); } return suggestions.slice(0, 5); // 最多返回5条建议 }