galaxy-opc-plugin 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,582 @@
1
+ /**
2
+ * 星环OPC中心 — Dashboard 监控中心 UI
3
+ *
4
+ * 生成 Dashboard 页面的 HTML/CSS,包含:
5
+ * - 4个关键指标卡片(本月收入、本月利润、现金余额、应收账款)
6
+ * - 风险预警列表
7
+ * - 今日待办列表
8
+ * - AI 建议区域
9
+ */
10
+
11
+ export interface DashboardMetrics {
12
+ monthlyIncome: number;
13
+ monthlyIncomeChange: number; // 同比变化百分比
14
+ monthlyProfit: number;
15
+ monthlyProfitChange: number;
16
+ cashBalance: number;
17
+ monthsOfRunway: number; // 可撑月数
18
+ receivables: number;
19
+ overdueReceivables: number;
20
+ }
21
+
22
+ export interface DashboardAlert {
23
+ id: string;
24
+ title: string;
25
+ severity: 'critical' | 'warning' | 'info';
26
+ category: string;
27
+ message: string;
28
+ created_at: string;
29
+ }
30
+
31
+ export interface DashboardTodo {
32
+ id: string;
33
+ title: string;
34
+ priority: 'urgent' | 'high' | 'normal';
35
+ category: string;
36
+ due_date?: string;
37
+ description?: string;
38
+ }
39
+
40
+ export interface DashboardSuggestion {
41
+ id: string;
42
+ title: string;
43
+ description: string;
44
+ action?: {
45
+ label: string;
46
+ url?: string;
47
+ onclick?: string;
48
+ };
49
+ }
50
+
51
+ export interface DashboardData {
52
+ metrics: DashboardMetrics;
53
+ alerts: DashboardAlert[];
54
+ todos: DashboardTodo[];
55
+ suggestions: DashboardSuggestion[];
56
+ }
57
+
58
+ /**
59
+ * 生成 Dashboard HTML
60
+ */
61
+ export function generateDashboardHtml(data: DashboardData): string {
62
+ return `
63
+ <div class="dashboard-container">
64
+ ${generateMetricsSection(data.metrics)}
65
+ <div class="dashboard-two-column">
66
+ <div class="dashboard-left-column">
67
+ ${generateAlertsSection(data.alerts)}
68
+ ${generateTodosSection(data.todos)}
69
+ </div>
70
+ <div class="dashboard-right-column">
71
+ ${generateSuggestionsSection(data.suggestions)}
72
+ </div>
73
+ </div>
74
+ </div>
75
+ `;
76
+ }
77
+
78
+ /**
79
+ * 生成关键指标卡片区域
80
+ */
81
+ function generateMetricsSection(metrics: DashboardMetrics): string {
82
+ const formatMoney = (amount: number) => {
83
+ if (amount >= 10000) {
84
+ return (amount / 10000).toFixed(1) + '万';
85
+ }
86
+ return amount.toFixed(0);
87
+ };
88
+
89
+ const formatChange = (change: number) => {
90
+ const sign = change >= 0 ? '+' : '';
91
+ const className = change >= 0 ? 'trend-up' : 'trend-down';
92
+ const arrow = change >= 0 ? '↑' : '↓';
93
+ return `<span class="${className}">${arrow} ${sign}${change.toFixed(1)}%</span>`;
94
+ };
95
+
96
+ return `
97
+ <div class="stats-grid dashboard-metrics">
98
+ <div class="stat-card">
99
+ <div class="label">
100
+ <span>本月收入</span>
101
+ ${formatChange(metrics.monthlyIncomeChange)}
102
+ </div>
103
+ <div class="value">
104
+ ¥${formatMoney(metrics.monthlyIncome)}
105
+ <span class="unit">元</span>
106
+ </div>
107
+ </div>
108
+
109
+ <div class="stat-card">
110
+ <div class="label">
111
+ <span>本月利润</span>
112
+ ${formatChange(metrics.monthlyProfitChange)}
113
+ </div>
114
+ <div class="value">
115
+ ¥${formatMoney(metrics.monthlyProfit)}
116
+ <span class="unit">元</span>
117
+ </div>
118
+ </div>
119
+
120
+ <div class="stat-card ${metrics.monthsOfRunway < 2 ? 'stat-card-warning' : ''}">
121
+ <div class="label">
122
+ <span>现金余额</span>
123
+ ${metrics.monthsOfRunway < 2 ? '<span class="badge badge-critical">预警</span>' : ''}
124
+ </div>
125
+ <div class="value">
126
+ ¥${formatMoney(metrics.cashBalance)}
127
+ <span class="unit">元</span>
128
+ </div>
129
+ <div class="stat-card-footer">
130
+ 可撑 ${metrics.monthsOfRunway.toFixed(1)} 个月
131
+ </div>
132
+ </div>
133
+
134
+ <div class="stat-card ${metrics.overdueReceivables > 0 ? 'stat-card-warning' : ''}">
135
+ <div class="label">
136
+ <span>应收账款</span>
137
+ ${metrics.overdueReceivables > 0 ? '<span class="badge badge-warning">逾期</span>' : ''}
138
+ </div>
139
+ <div class="value">
140
+ ¥${formatMoney(metrics.receivables)}
141
+ <span class="unit">元</span>
142
+ </div>
143
+ ${metrics.overdueReceivables > 0 ? `
144
+ <div class="stat-card-footer stat-card-footer-warning">
145
+ 逾期金额: ¥${formatMoney(metrics.overdueReceivables)}
146
+ </div>
147
+ ` : ''}
148
+ </div>
149
+ </div>
150
+ `;
151
+ }
152
+
153
+ /**
154
+ * 生成风险预警列表
155
+ */
156
+ function generateAlertsSection(alerts: DashboardAlert[]): string {
157
+ if (alerts.length === 0) {
158
+ return `
159
+ <div class="card dashboard-section">
160
+ <h2>风险预警</h2>
161
+ <div class="dashboard-empty-state">
162
+ <div class="dashboard-empty-icon">✓</div>
163
+ <p>暂无风险预警</p>
164
+ </div>
165
+ </div>
166
+ `;
167
+ }
168
+
169
+ const alertItems = alerts.map(alert => {
170
+ const severityClass = `alert-${alert.severity}`;
171
+ const severityIcon = alert.severity === 'critical' ? '⚠️' :
172
+ alert.severity === 'warning' ? '⚡' : 'ℹ️';
173
+
174
+ return `
175
+ <div class="alert-banner ${severityClass}" data-alert-id="${alert.id}">
176
+ <span class="alert-icon">${severityIcon}</span>
177
+ <div class="alert-content">
178
+ <div class="alert-title">${alert.title}</div>
179
+ <div class="alert-message">${alert.message}</div>
180
+ </div>
181
+ <button class="alert-dismiss" onclick="dismissAlert('${alert.id}')" title="忽略">×</button>
182
+ </div>
183
+ `;
184
+ }).join('');
185
+
186
+ return `
187
+ <div class="card dashboard-section">
188
+ <h2>风险预警 <span class="badge badge-warning">${alerts.length}</span></h2>
189
+ <div class="dashboard-alerts">
190
+ ${alertItems}
191
+ </div>
192
+ </div>
193
+ `;
194
+ }
195
+
196
+ /**
197
+ * 生成今日待办列表
198
+ */
199
+ function generateTodosSection(todos: DashboardTodo[]): string {
200
+ if (todos.length === 0) {
201
+ return `
202
+ <div class="card dashboard-section">
203
+ <h2>今日待办</h2>
204
+ <div class="dashboard-empty-state">
205
+ <div class="dashboard-empty-icon">✓</div>
206
+ <p>太棒了!今天没有待办事项</p>
207
+ </div>
208
+ </div>
209
+ `;
210
+ }
211
+
212
+ // 按优先级排序
213
+ const sortedTodos = [...todos].sort((a, b) => {
214
+ const priorityOrder = { urgent: 0, high: 1, normal: 2 };
215
+ return priorityOrder[a.priority] - priorityOrder[b.priority];
216
+ });
217
+
218
+ const todoItems = sortedTodos.map(todo => {
219
+ const priorityBadge = todo.priority === 'urgent' ? 'badge-critical' :
220
+ todo.priority === 'high' ? 'badge-warning' : 'badge-info';
221
+ const priorityLabel = todo.priority === 'urgent' ? '紧急' :
222
+ todo.priority === 'high' ? '重要' : '一般';
223
+
224
+ return `
225
+ <div class="dashboard-todo-item">
226
+ <div class="dashboard-todo-header">
227
+ <span class="badge ${priorityBadge}">${priorityLabel}</span>
228
+ <span class="dashboard-todo-category">${todo.category}</span>
229
+ </div>
230
+ <div class="dashboard-todo-title">${todo.title}</div>
231
+ ${todo.description ? `<div class="dashboard-todo-desc">${todo.description}</div>` : ''}
232
+ ${todo.due_date ? `<div class="dashboard-todo-due">截止: ${todo.due_date}</div>` : ''}
233
+ </div>
234
+ `;
235
+ }).join('');
236
+
237
+ return `
238
+ <div class="card dashboard-section">
239
+ <h2>今日待办 <span class="badge badge-info">${todos.length}</span></h2>
240
+ <div class="dashboard-todos">
241
+ ${todoItems}
242
+ </div>
243
+ </div>
244
+ `;
245
+ }
246
+
247
+ /**
248
+ * 生成 AI 建议区域
249
+ */
250
+ function generateSuggestionsSection(suggestions: DashboardSuggestion[]): string {
251
+ if (suggestions.length === 0) {
252
+ return `
253
+ <div class="card dashboard-section">
254
+ <h2>AI 建议</h2>
255
+ <div class="dashboard-empty-state">
256
+ <div class="dashboard-empty-icon">💡</div>
257
+ <p>暂无建议</p>
258
+ </div>
259
+ </div>
260
+ `;
261
+ }
262
+
263
+ const suggestionItems = suggestions.map(suggestion => {
264
+ const actionButton = suggestion.action ? `
265
+ <button
266
+ class="dashboard-suggestion-action"
267
+ ${suggestion.action.onclick ? `onclick="${suggestion.action.onclick}"` : ''}
268
+ ${suggestion.action.url ? `onclick="window.location.href='${suggestion.action.url}'"` : ''}
269
+ >
270
+ ${suggestion.action.label}
271
+ </button>
272
+ ` : '';
273
+
274
+ return `
275
+ <div class="dashboard-suggestion-item">
276
+ <div class="dashboard-suggestion-title">${suggestion.title}</div>
277
+ <div class="dashboard-suggestion-desc">${suggestion.description}</div>
278
+ ${actionButton}
279
+ </div>
280
+ `;
281
+ }).join('');
282
+
283
+ return `
284
+ <div class="card dashboard-section">
285
+ <h2>💡 AI 建议</h2>
286
+ <div class="dashboard-suggestions">
287
+ ${suggestionItems}
288
+ </div>
289
+ </div>
290
+ `;
291
+ }
292
+
293
+ /**
294
+ * Dashboard 专用 CSS
295
+ */
296
+ export function getDashboardCss(): string {
297
+ return `
298
+ /* Dashboard Container */
299
+ .dashboard-container {
300
+ animation: fadeIn 0.3s ease;
301
+ }
302
+
303
+ .dashboard-two-column {
304
+ display: grid;
305
+ grid-template-columns: 1fr 400px;
306
+ gap: 20px;
307
+ margin-top: 20px;
308
+ }
309
+
310
+ @media (max-width: 1200px) {
311
+ .dashboard-two-column {
312
+ grid-template-columns: 1fr;
313
+ }
314
+ }
315
+
316
+ /* Metrics Cards */
317
+ .dashboard-metrics {
318
+ margin-bottom: 28px;
319
+ }
320
+
321
+ .stat-card-warning {
322
+ border-color: #fde68a;
323
+ background: linear-gradient(135deg, #fffbeb 0%, #ffffff 100%);
324
+ }
325
+
326
+ .stat-card-footer {
327
+ margin-top: 12px;
328
+ font-size: 12px;
329
+ color: var(--tx3);
330
+ }
331
+
332
+ .stat-card-footer-warning {
333
+ color: #92400e;
334
+ font-weight: 500;
335
+ }
336
+
337
+ /* Dashboard Sections */
338
+ .dashboard-section {
339
+ margin-bottom: 20px;
340
+ }
341
+
342
+ /* Empty State */
343
+ .dashboard-empty-state {
344
+ text-align: center;
345
+ padding: 40px 20px;
346
+ }
347
+
348
+ .dashboard-empty-icon {
349
+ font-size: 48px;
350
+ margin-bottom: 12px;
351
+ opacity: 0.3;
352
+ }
353
+
354
+ .dashboard-empty-state p {
355
+ color: var(--tx3);
356
+ font-size: 14px;
357
+ }
358
+
359
+ /* Alerts */
360
+ .dashboard-alerts {
361
+ display: flex;
362
+ flex-direction: column;
363
+ gap: 8px;
364
+ }
365
+
366
+ .alert-banner {
367
+ position: relative;
368
+ padding-right: 40px;
369
+ }
370
+
371
+ .alert-icon {
372
+ font-size: 18px;
373
+ line-height: 1;
374
+ }
375
+
376
+ .alert-content {
377
+ flex: 1;
378
+ }
379
+
380
+ .alert-title {
381
+ font-weight: 600;
382
+ margin-bottom: 2px;
383
+ }
384
+
385
+ .alert-message {
386
+ font-size: 12px;
387
+ opacity: 0.9;
388
+ }
389
+
390
+ .alert-dismiss {
391
+ position: absolute;
392
+ top: 8px;
393
+ right: 8px;
394
+ width: 24px;
395
+ height: 24px;
396
+ border: none;
397
+ background: rgba(0, 0, 0, 0.05);
398
+ border-radius: 4px;
399
+ cursor: pointer;
400
+ font-size: 18px;
401
+ line-height: 1;
402
+ color: currentColor;
403
+ opacity: 0.5;
404
+ transition: all 0.15s;
405
+ }
406
+
407
+ .alert-dismiss:hover {
408
+ opacity: 1;
409
+ background: rgba(0, 0, 0, 0.1);
410
+ }
411
+
412
+ /* Todos */
413
+ .dashboard-todos {
414
+ display: flex;
415
+ flex-direction: column;
416
+ gap: 12px;
417
+ }
418
+
419
+ .dashboard-todo-item {
420
+ padding: 14px;
421
+ background: #f9fafb;
422
+ border-radius: 6px;
423
+ border-left: 3px solid var(--bd);
424
+ transition: all 0.15s;
425
+ }
426
+
427
+ .dashboard-todo-item:hover {
428
+ background: #f3f4f6;
429
+ border-left-color: var(--tx);
430
+ }
431
+
432
+ .dashboard-todo-header {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 8px;
436
+ margin-bottom: 6px;
437
+ }
438
+
439
+ .dashboard-todo-category {
440
+ font-size: 11px;
441
+ color: var(--tx3);
442
+ text-transform: uppercase;
443
+ letter-spacing: 0.05em;
444
+ }
445
+
446
+ .dashboard-todo-title {
447
+ font-weight: 600;
448
+ font-size: 14px;
449
+ color: var(--tx);
450
+ margin-bottom: 4px;
451
+ }
452
+
453
+ .dashboard-todo-desc {
454
+ font-size: 12px;
455
+ color: var(--tx2);
456
+ margin-bottom: 6px;
457
+ }
458
+
459
+ .dashboard-todo-due {
460
+ font-size: 11px;
461
+ color: var(--tx3);
462
+ }
463
+
464
+ /* Suggestions */
465
+ .dashboard-suggestions {
466
+ display: flex;
467
+ flex-direction: column;
468
+ gap: 16px;
469
+ }
470
+
471
+ .dashboard-suggestion-item {
472
+ padding: 16px;
473
+ background: linear-gradient(135deg, #f0f9ff 0%, #ffffff 100%);
474
+ border-radius: 8px;
475
+ border: 1px solid #bae6fd;
476
+ }
477
+
478
+ .dashboard-suggestion-title {
479
+ font-weight: 600;
480
+ font-size: 14px;
481
+ color: var(--tx);
482
+ margin-bottom: 6px;
483
+ }
484
+
485
+ .dashboard-suggestion-desc {
486
+ font-size: 13px;
487
+ color: var(--tx2);
488
+ margin-bottom: 12px;
489
+ line-height: 1.5;
490
+ }
491
+
492
+ .dashboard-suggestion-action {
493
+ padding: 8px 16px;
494
+ background: var(--tx);
495
+ color: white;
496
+ border: none;
497
+ border-radius: 6px;
498
+ font-size: 13px;
499
+ font-weight: 500;
500
+ cursor: pointer;
501
+ transition: all 0.15s;
502
+ font-family: var(--font);
503
+ }
504
+
505
+ .dashboard-suggestion-action:hover {
506
+ background: var(--pri-l);
507
+ transform: translateY(-1px);
508
+ }
509
+
510
+ /* Mobile Responsive */
511
+ @media (max-width: 768px) {
512
+ .stats-grid {
513
+ grid-template-columns: 1fr;
514
+ }
515
+
516
+ .dashboard-two-column {
517
+ grid-template-columns: 1fr;
518
+ }
519
+
520
+ .main {
521
+ padding: 20px 16px;
522
+ }
523
+ }
524
+ `;
525
+ }
526
+
527
+ /**
528
+ * Dashboard 专用 JavaScript
529
+ */
530
+ export function getDashboardJs(): string {
531
+ return `
532
+ // 忽略预警
533
+ async function dismissAlert(alertId) {
534
+ if (!confirm('确定忽略此预警?')) return;
535
+
536
+ try {
537
+ const res = await fetch('/opc/admin/api/alerts/' + alertId + '/dismiss', {
538
+ method: 'POST',
539
+ headers: { 'Content-Type': 'application/json' }
540
+ });
541
+
542
+ if (res.ok) {
543
+ showToast('预警已忽略', 'ok');
544
+ // 移除元素
545
+ const alertEl = document.querySelector('[data-alert-id="' + alertId + '"]');
546
+ if (alertEl) {
547
+ alertEl.style.opacity = '0';
548
+ setTimeout(() => alertEl.remove(), 200);
549
+ }
550
+ } else {
551
+ showToast('操作失败', 'err');
552
+ }
553
+ } catch (err) {
554
+ showToast('网络错误', 'err');
555
+ }
556
+ }
557
+
558
+ // 加载 Dashboard 数据
559
+ async function loadDashboard() {
560
+ showLoading();
561
+
562
+ try {
563
+ const res = await fetch('/opc/admin/api/dashboard');
564
+ if (!res.ok) throw new Error('加载失败');
565
+
566
+ const data = await res.json();
567
+ renderDashboard(data);
568
+ } catch (err) {
569
+ document.getElementById('app').innerHTML =
570
+ '<div class="card"><p style="color:var(--err);">加载失败: ' + err.message + '</p></div>';
571
+ } finally {
572
+ hideLoading();
573
+ }
574
+ }
575
+
576
+ // 渲染 Dashboard
577
+ function renderDashboard(data) {
578
+ // Dashboard HTML 已在服务端生成,这里只需要绑定事件
579
+ // 实际渲染逻辑在 config-ui.ts 的 renderDashboard 函数中
580
+ }
581
+ `;
582
+ }
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * 星环OPC中心 — 产品官网/文档页
3
3
  *
4
- * 路由: /opc/home/*
4
+ * 路由: / (根路径)
5
5
  * 纯静态 HTML 单页,展示产品功能和安装指南
6
6
  */
7
7
 
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
8
11
  import type { ServerResponse } from "node:http";
9
12
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
13
 
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+
11
17
  function sendHtml(res: ServerResponse, html: string): void {
12
18
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
13
19
  res.end(html);
@@ -208,7 +214,7 @@ function buildLandingHtml(): string {
208
214
  http://localhost:18789/opc/admin
209
215
 
210
216
  # 访问产品官网
211
- http://localhost:18789/opc/home</pre>
217
+ http://localhost:18789/</pre>
212
218
  </div>
213
219
  </div>
214
220
  </section>
@@ -252,18 +258,62 @@ http://localhost:18789/opc/home</pre>
252
258
  }
253
259
 
254
260
  export function registerLandingPage(api: OpenClawPluginApi): void {
261
+ // opc-homepage 前端页面路径(项目根目录下)
262
+ const homepageDir = path.resolve(__dirname, "../../../../opc-homepage");
263
+ const homepageIndexPath = path.join(homepageDir, "index.html");
264
+
255
265
  api.registerHttpHandler(async (req, res) => {
256
266
  const rawUrl = req.url ?? "";
257
267
  const urlObj = new URL(rawUrl, "http://localhost");
258
268
  const pathname = urlObj.pathname;
259
269
 
260
- if (!pathname.startsWith("/opc/home")) {
270
+ // 1. 根路径 `/` → 返回 opc-homepage/index.html
271
+ if (pathname === "/" || pathname === "") {
272
+ try {
273
+ if (fs.existsSync(homepageIndexPath)) {
274
+ const html = fs.readFileSync(homepageIndexPath, "utf-8");
275
+ sendHtml(res, html);
276
+ return true;
277
+ }
278
+ } catch (err) {
279
+ api.logger.warn(`opc: 无法读取主页文件: ${err instanceof Error ? err.message : String(err)}`);
280
+ }
281
+ // 降级到内置页面
282
+ sendHtml(res, buildLandingHtml());
283
+ return true;
284
+ }
285
+
286
+ // 2. 静态资源 `/galaxy.png` 等 → 返回 opc-homepage/ 下的文件
287
+ if (pathname.match(/^\/(galaxy.*\.png|.*\.jpg|.*\.svg|.*\.css|.*\.js)$/)) {
288
+ try {
289
+ const filename = path.basename(pathname);
290
+ const filePath = path.join(homepageDir, filename);
291
+
292
+ if (fs.existsSync(filePath)) {
293
+ const ext = path.extname(filename).toLowerCase();
294
+ const mimeTypes: Record<string, string> = {
295
+ ".png": "image/png",
296
+ ".jpg": "image/jpeg",
297
+ ".jpeg": "image/jpeg",
298
+ ".svg": "image/svg+xml",
299
+ ".css": "text/css",
300
+ ".js": "application/javascript",
301
+ };
302
+ const contentType = mimeTypes[ext] || "application/octet-stream";
303
+
304
+ const fileContent = fs.readFileSync(filePath);
305
+ res.writeHead(200, { "Content-Type": contentType });
306
+ res.end(fileContent);
307
+ return true;
308
+ }
309
+ } catch (err) {
310
+ api.logger.warn(`opc: 无法读取静态资源: ${err instanceof Error ? err.message : String(err)}`);
311
+ }
261
312
  return false;
262
313
  }
263
314
 
264
- sendHtml(res, buildLandingHtml());
265
- return true;
315
+ return false;
266
316
  });
267
317
 
268
- api.logger.info("opc: 已注册产品官网 (/opc/home)");
318
+ api.logger.info("opc: 已注册产品官网 (/)");
269
319
  }