methodalgo-cli 1.0.1

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,579 @@
1
+ import { Command } from "commander";
2
+ import React, { useState, useEffect, useRef } from "react";
3
+ import { render, Box, Text, useInput, useApp } from "ink";
4
+ import { signedRequest } from "../utils/api.js";
5
+ import { t, getLang } from "../utils/i18n.js";
6
+
7
+ const h = React.createElement;
8
+
9
+ // ── 渐变色工具 ─────────────────────────────────────────
10
+ const gradientText = (text, fromRGB, toRGB) => {
11
+ const len = text.length;
12
+ if (len === 0) return [];
13
+ return [...text].map((ch, i) => {
14
+ const ratio = len > 1 ? i / (len - 1) : 0;
15
+ const r = Math.round(fromRGB[0] + (toRGB[0] - fromRGB[0]) * ratio);
16
+ const g = Math.round(fromRGB[1] + (toRGB[1] - fromRGB[1]) * ratio);
17
+ const b = Math.round(fromRGB[2] + (toRGB[2] - fromRGB[2]) * ratio);
18
+ return h(Text, { key: i, color: `rgb(${r},${g},${b})`, bold: true }, ch);
19
+ });
20
+ };
21
+
22
+ // ── Spinner 组件 ───────────────────────────────────────
23
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
24
+ const Spinner = ({ color = "red" }) => {
25
+ const [frame, setFrame] = useState(0);
26
+ useEffect(() => {
27
+ const t = setInterval(() => setFrame(f => (f + 1) % SPINNER_FRAMES.length), 80);
28
+ return () => clearInterval(t);
29
+ }, []);
30
+ return h(Text, { color, bold: true }, SPINNER_FRAMES[frame]);
31
+ };
32
+
33
+ // ── 通用工具 ────────────────────────────────────────────────
34
+ const cleanText = (text) => {
35
+ if (!text) return "";
36
+ return text.replace(/[\r\n]+/g, ", ").replace(/[\uD83C\uD83D\uD83E][\uDC00-\uDFFF]|[\u2600-\u27BF]/g, "").trim();
37
+ };
38
+
39
+ const formatTime = (iso) => {
40
+ try {
41
+ const d = new Date(iso);
42
+ const now = new Date();
43
+ const isToday = d.toDateString() === now.toDateString();
44
+ return isToday
45
+ ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
46
+ : `${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
47
+ } catch { return "--:--"; }
48
+ };
49
+
50
+ // ── 世界时钟组件 ───────────────────────────────────────────
51
+ const ClockPanel = ({ focused }) => {
52
+ const [now, setNow] = useState(new Date());
53
+ useEffect(() => {
54
+ const timer = setInterval(() => setNow(new Date()), 1000);
55
+ return () => clearInterval(timer);
56
+ }, []);
57
+ const opts = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
58
+ const bc = focused ? "red" : "white";
59
+ return h(Box, { flexDirection: "column", borderStyle: "single", borderColor: bc, flexGrow: 0, height: 4, paddingX: 1, overflow: "hidden" },
60
+ h(Box, { flexDirection: "row" },
61
+ h(Text, { bold: true, color: "yellow" }, " 🕒 Market clock")
62
+ ),
63
+ h(Box, { flexDirection: "row", justifyContent: "space-between" },
64
+ h(Text, null, `${now.toLocaleTimeString("zh-CN", opts)} (LOCAL) `),
65
+ h(Text, null, `${now.toLocaleTimeString("en-GB", { ...opts, timeZone: "Europe/London" })} (LSE) `),
66
+ h(Text, null, `${now.toLocaleTimeString("en-US", { ...opts, timeZone: "America/New_York" })} (NYSE) `)
67
+ )
68
+ );
69
+ };
70
+
71
+ // ── 可滚动列表组件(虚拟滚动) ────────────────────────────────
72
+ const PanelList = ({ label, items, focused, onSelect, maxVisible = 6 }) => {
73
+ const [selectedIdx, setSelectedIdx] = useState(0);
74
+ const [scrollTop, setScrollTop] = useState(0);
75
+ const bc = focused ? "red" : "white";
76
+
77
+ useInput((input, key) => {
78
+ if (!focused) return;
79
+ if (key.upArrow) {
80
+ setSelectedIdx(i => {
81
+ const next = Math.max(0, i - 1);
82
+ // 选中项超出可视窗口上边界时,滚动窗口
83
+ setScrollTop(st => (next < st ? next : st));
84
+ return next;
85
+ });
86
+ }
87
+ if (key.downArrow) {
88
+ setSelectedIdx(i => {
89
+ const next = Math.min(items.length - 1, i + 1);
90
+ setScrollTop(st => (next >= st + maxVisible ? next - maxVisible + 1 : st));
91
+ return next;
92
+ });
93
+ }
94
+ if (key.return) onSelect(selectedIdx);
95
+ });
96
+
97
+ useEffect(() => {
98
+ setSelectedIdx(i => Math.min(i, Math.max(0, items.length - 1)));
99
+ setScrollTop(st => Math.min(st, Math.max(0, items.length - maxVisible)));
100
+ }, [items.length]);
101
+
102
+ // 只渲染可视窗口内的条目
103
+ const visibleItems = items.slice(scrollTop, scrollTop + maxVisible);
104
+ const hasMore = items.length > scrollTop + maxVisible;
105
+ const hasLess = scrollTop > 0;
106
+
107
+ // 标题行:始终显示,标题固定红色
108
+ const countLabel = items.length > 0 ? ` (${items.length})` : "";
109
+ const scrollHint = hasLess && hasMore ? " ↕" : hasLess ? " ↑" : hasMore ? " ↓" : "";
110
+ return h(Box, { flexDirection: "column", borderStyle: "single", borderColor: bc, flexGrow: 1, overflow: "hidden" },
111
+ h(Text, { bold: true, color: "red", wrap: "truncate" }, ` ${label}${countLabel}${scrollHint}`),
112
+ items.length === 0
113
+ ? h(Box, { flexGrow: 1, alignItems: "center", justifyContent: "center" },
114
+ ...gradientText("Loading...", [255, 60, 60], [255, 255, 255]))
115
+ : visibleItems.map((item, vi) => {
116
+ const realIdx = scrollTop + vi;
117
+ const isFocused = realIdx === selectedIdx && focused;
118
+
119
+ // 颜色逻辑:选中状态保持红色背景,非选中状态根据方向上色
120
+ let textColor = "white";
121
+ if (!isFocused && item.direction === "bear") textColor = "red";
122
+
123
+ return h(Text, {
124
+ key: realIdx,
125
+ backgroundColor: isFocused ? "red" : undefined,
126
+ color: textColor,
127
+ wrap: "truncate"
128
+ }, ` [${formatTime(item.publish_date || item.timestamp)}] ${item.displayTitle || ""}`);
129
+ })
130
+ );
131
+ };
132
+
133
+ // ── 详情弹窗(全屏替换主界面) ─────────────────────────
134
+ const DetailDialog = ({ data, category, onClose }) => {
135
+ const [scrollOffset, setScrollOffset] = useState(0);
136
+ const termRows = process.stdout.rows || 40;
137
+ const termCols = process.stdout.columns || 80;
138
+ // 结构化渲染内容
139
+ const title = data.displayTitle || data.title?.[getLang()] || data.title?.en || data.title || "Detail";
140
+ const time = data?.publish_date || data?.timestamp || "";
141
+ const rawUrl = data?.url || "";
142
+ const url = (rawUrl === "N/A" || !rawUrl) ? "" : rawUrl;
143
+
144
+ let content = data?.content || data?.fullText || "";
145
+ if (content.includes("No detailed content available.") || content === "No detailed content") content = "";
146
+
147
+ const contentLines = content ? content.split("\n") : [];
148
+ const HEADER = 8;
149
+ const FOOTER = 3;
150
+ const VISIBLE = Math.max(3, termRows - HEADER - FOOTER);
151
+
152
+ useInput((input, key) => {
153
+ if (key.escape || key.return || input === "q") onClose();
154
+ if (key.upArrow) setScrollOffset(o => Math.max(0, o - 1));
155
+ if (key.downArrow) setScrollOffset(o => Math.min(Math.max(0, contentLines.length - VISIBLE), o + 1));
156
+ });
157
+
158
+ const sep = "─".repeat(Math.min(60, termCols - 6));
159
+ const visibleContent = contentLines.slice(scrollOffset, scrollOffset + VISIBLE);
160
+
161
+ return h(Box, {
162
+ flexDirection: "column", borderStyle: "double", borderColor: "red",
163
+ paddingX: 1, width: "100%", height: termRows
164
+ },
165
+ // Category Label (Large)
166
+ h(Box, { marginBottom: 1 },
167
+ h(Text, { backgroundColor: "red", color: "white", bold: true }, ` ${(category || "Detail").toUpperCase()} `)
168
+ ),
169
+ // Title (Bold)
170
+ h(Text, { color: "yellow", bold: true, wrap: "wrap" }, title),
171
+ h(Box, { height: 1 }), // Spacer
172
+ // Metadata
173
+ h(Box, { gap: 2 },
174
+ h(Text, null, h(Text, { color: "gray" }, "Time: "), h(Text, { color: "cyan" }, time || "N/A")),
175
+ ),
176
+ url ? h(Text, { color: "gray", dimColor: true, wrap: "truncate" }, `URL: ${url}`) : null,
177
+ data?.attachments?.length > 0 ? h(Box, { flexDirection: "column" },
178
+ data.attachments.map((att, i) => {
179
+ const aurl = typeof att === "string" ? att : (att.url || att.proxy_url);
180
+ return aurl ? h(Text, { key: i, color: "blue", wrap: "truncate" }, `Attachment: ${aurl}`) : null;
181
+ })
182
+ ) : null,
183
+
184
+ // Content Section
185
+ content ? h(Box, { flexDirection: "column", flexGrow: 1, marginTop: 1 },
186
+ h(Text, { color: "gray", dimColor: true }, sep),
187
+ ...visibleContent.map((line, i) => h(Text, { key: i, wrap: "wrap", color: "white" }, line || " "))
188
+ ) : h(Box, { flexGrow: 1 }),
189
+
190
+ // Scroll Info
191
+ contentLines.length > VISIBLE
192
+ ? h(Text, { color: "gray" }, ` Scroll: Up/Down (${scrollOffset + 1}-${Math.min(scrollOffset + VISIBLE, contentLines.length)}/${contentLines.length})`)
193
+ : null,
194
+ // Toolbar
195
+ h(Box, { justifyContent: "center", borderStyle: "single", borderColor: "gray", borderTop: true, borderBottom: false, borderLeft: false, borderRight: false },
196
+ h(Text, { backgroundColor: "red", color: "white", bold: true }, " ESC "),
197
+ h(Text, { color: "gray" }, " Close "),
198
+ h(Text, { backgroundColor: "gray", color: "white", bold: true }, " Up/Dn "),
199
+ h(Text, { color: "gray" }, " Scroll")
200
+ )
201
+ );
202
+ };
203
+
204
+ // ── Loading 屏幕(带 Spinner) ───────────────────────────────
205
+ const LoadingScreen = () => {
206
+ const [dots, setDots] = useState(1);
207
+ useEffect(() => {
208
+ const timer = setInterval(() => setDots(d => (d % 3) + 1), 400);
209
+ return () => clearInterval(timer);
210
+ }, []);
211
+ return h(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%" },
212
+ h(Text, { color: "red", bold: true }, "▄▄▄ ▄▄▄ ▄▄ ▄▄ ▄▄▄▄ ▄▄ "),
213
+ h(Text, { color: "red", bold: true }, "████▄ ▄████ ██ ██ ██ ▄██▀▀██▄ ██ "),
214
+ h(Text, { color: "red", bold: true }, "███▀████▀███ ▄█▀█▄ ▀██▀▀ ████▄ ▄███▄ ▄████ ███ ███ ██ ▄████ ▄███▄ "),
215
+ h(Text, { color: "red", bold: true }, "███ ▀▀ ███ ██▄█▀ ██ ██ ██ ██ ██ ██ ██ ███▀▀███ ██ ██ ██ ██ ██ "),
216
+ h(Text, { color: "white", bold: true }, "███ ███ ▀█▄▄▄ ██ ██ ██ ▀███▀ ▀████ ███ ███ ██ ▀████ ▀███▀ "),
217
+ h(Text, null, " "),
218
+ h(Box, { gap: 1 },
219
+ h(Spinner, { color: "red" }),
220
+ ...gradientText(`Fetching Global Alpha Insights${".".repeat(dots)}`, [255, 60, 60], [255, 255, 255])
221
+ )
222
+ );
223
+ };
224
+
225
+ // ── 主 Dashboard 组件 ──────────────────────────────────────
226
+ const PANELS = ["article", "breaking", "onchain", "report", "breakout", "exhaustion", "goldenPit", "liquidation", "clock", "marketToday", "tokenUnlock"];
227
+
228
+ const Dashboard = () => {
229
+ const { exit } = useApp();
230
+ const [loading, setLoading] = useState(true);
231
+ const [authError, setAuthError] = useState(false);
232
+ const [focusIdx, setFocusIdx] = useState(0);
233
+ const [dialog, setDialog] = useState(null);
234
+ const [caches, setCaches] = useState({
235
+ article: [], breaking: [], onchain: [], report: [],
236
+ breakout: [], exhaustion: [], goldenPit: [], liquidation: [],
237
+ marketToday: [], tokenUnlock: []
238
+ });
239
+ const [statusInfo, setStatusInfo] = useState({ time: "", mem: "0", error: null });
240
+ const dataTimerRef = useRef(null);
241
+ const lastFetchRef = useRef(null); // 记录上次拉取时间,用于增量请求
242
+ const lang = getLang();
243
+
244
+ const refreshData = async () => {
245
+ const memUsage = (process.memoryUsage().rss / 1024 / 1024).toFixed(1);
246
+ try {
247
+ const newsTypes = ["article", "breaking", "onchain", "report"];
248
+ const signalChannels = ["breakout-mtf", "exhaustion-buyer", "exhaustion-seller", "golden-pit-ltf", "golden-pit-mtf", "liquidation", "market-today", "token-unlock"];
249
+ const isFirstFetch = !lastFetchRef.current;
250
+ const newsLimit = isFirstFetch ? 100 : 20;
251
+ const sigLimit = isFirstFetch ? 50 : 15;
252
+ const startDate = isFirstFetch ? undefined : lastFetchRef.current;
253
+
254
+ const promises = [
255
+ ...newsTypes.map(type => signedRequest("/mcp/news", { type, limit: newsLimit, lang, ...(startDate ? { startDate } : {}) })),
256
+ ...signalChannels.map(channelName => signedRequest("/mcp/signals", { channelName, limit: sigLimit }))
257
+ ];
258
+ const results = await Promise.allSettled(promises);
259
+
260
+ const authFailed = results.some(r =>
261
+ (r.status === "rejected" && r.reason?.response?.status === 401) ||
262
+ (r.status === "fulfilled" && r.value.data.status === false &&
263
+ (r.value.data.msg?.includes("auth") || r.value.data.msg?.includes("key")))
264
+ );
265
+ if (authFailed) { setAuthError(true); setLoading(false); if (dataTimerRef.current) clearInterval(dataTimerRef.current); return; }
266
+
267
+ lastFetchRef.current = new Date().toISOString();
268
+
269
+ setCaches(prev => {
270
+ const next = { ...prev };
271
+ // Process News
272
+ newsTypes.forEach((type, idx) => {
273
+ const res = results[idx];
274
+ if (res.status === "fulfilled" && res.value.data.status) {
275
+ const newData = res.value.data.data;
276
+ const existingTitles = new Set(prev[type].map(n => n.title?.[lang] || n.title?.en));
277
+ const uniqueNew = newData.filter(n => !existingTitles.has(n.title?.[lang] || n.title?.en));
278
+ next[type] = [...uniqueNew, ...prev[type]].slice(0, 100).map(item => {
279
+ const rawTitle = (typeof item.title === "object" && item.title !== null) ? (item.title[lang] || item.title.en || "") : (item.title || "");
280
+ const rawContent = item.content || item.summary || item.excerpt || item.description || "";
281
+ const content = (typeof rawContent === "object" && rawContent !== null) ? (rawContent[lang] || rawContent.en || JSON.stringify(rawContent)) : (rawContent || "No detailed content available.");
282
+
283
+ item.fullText = `Title: ${rawTitle}\n\nTime: ${item.publish_date}\n\nURL: ${item.url || "N/A"}\n\n--- Content ---\n${content}`;
284
+ item.displayTitle = cleanText(rawTitle);
285
+ return item;
286
+ });
287
+ }
288
+ });
289
+
290
+ // Process Signals & Info
291
+ const sigStartIndex = newsTypes.length;
292
+ const getSigData = (channelOffset) => {
293
+ const res = results[sigStartIndex + channelOffset];
294
+ return (res?.status === "fulfilled" && res.value.data.status) ? res.value.data.data : [];
295
+ };
296
+
297
+ const formatSig = (item, overrideDir, type) => {
298
+ const sig = item.signals?.[0];
299
+ let breakPrice = sig?.details?.BreakPrice || sig?.breakPrice || sig?.break_price || item.breakPrice || item.break_price;
300
+ if (!breakPrice && sig?.fields) {
301
+ const field = sig.fields.find(f => f.name?.includes("BreakPrice") || f.name?.includes("Price"));
302
+ if (field) breakPrice = field.value;
303
+ }
304
+ let rawTitle = sig ? sig.title : (item.title || "Signal Tip");
305
+ const attachments = [...(item.attachments || [])];
306
+ if (sig?.image) attachments.push({ url: sig.image });
307
+
308
+ // 方向检测改进
309
+ let direction = overrideDir || item.direction || "";
310
+ const side = (sig?.side || sig?.details?.Side || "").toLowerCase();
311
+
312
+ if (!direction) {
313
+ const searchStr = `${rawTitle} ${sig?.description || ""} ${item.title || ""}`.toLowerCase();
314
+ if (side === "buy" || side === "up" || searchStr.includes("bull") || searchStr.includes("up") || searchStr.includes("exhaustion seller") || (type === "liquidation" && side === "buy")) {
315
+ direction = "bull";
316
+ } else if (side === "sell" || side === "down" || searchStr.includes("bear") || searchStr.includes("down") || searchStr.includes("exhaustion buyer") || (type === "liquidation" && side === "sell")) {
317
+ direction = "bear";
318
+ } else if (searchStr.includes("long")) {
319
+ direction = type === "liquidation" ? "bear" : "bull";
320
+ } else if (searchStr.includes("short")) {
321
+ direction = type === "liquidation" ? "bull" : "bear";
322
+ }
323
+ }
324
+ item.direction = direction;
325
+
326
+ // 标题重写逻辑 (根据需求精调)
327
+ let symbol = sig?.symbol || item.symbol || "";
328
+ if (!symbol) {
329
+ const m = rawTitle.match(/\s(?:[Ff]or|[Oo]n)\s+([A-Z0-9.]+)/);
330
+ if (m) symbol = m[1];
331
+ else {
332
+ const m2 = rawTitle.match(/\b([A-Z0-9.]+)[^\w]*$/);
333
+ if (m2) symbol = m2[1];
334
+ }
335
+ }
336
+
337
+ if (type === "goldenPit") {
338
+ const prefix = direction === "bull" ? t("GOLDEN_PIT_BULL") : t("GOLDEN_PIT_BEAR");
339
+ rawTitle = `${prefix} For ${symbol}`;
340
+ } else if (type === "breakout") {
341
+ const prefix = direction === "bull" ? t("BREAKOUT_UP") : t("BREAKOUT_DOWN");
342
+ rawTitle = `${prefix} For ${symbol}`;
343
+ } else if (type === "liquidation") {
344
+ const prefix = direction === "bull" ? t("LIQUIDATION_SHORT") : t("LIQUIDATION_LONG");
345
+ rawTitle = `${prefix} For ${symbol}`;
346
+ }
347
+
348
+ // Special Content Cleanup
349
+ let detailsText = JSON.stringify(item.signals || item, null, 2);
350
+ if (type === "marketToday" && sig?.description) {
351
+ const lines = sig.description.split("\n").filter(line => !line.trim().startsWith("http")).filter(line => !line.includes("Season Index"));
352
+ detailsText = lines.join("\n").trim();
353
+ } else if (type === "tokenUnlock" && sig?.description) {
354
+ detailsText = sig.description.split("\n").filter(l => !l.trim().startsWith("http")).join("\n").trim();
355
+ const tokens = [...detailsText.matchAll(/Token:\s*(\w+)/g)].map(m => m[1]);
356
+ if (tokens.length > 0) rawTitle = `Unlock: ${tokens.slice(0, 3).join(", ")}${tokens.length > 3 ? "..." : ""}`;
357
+ }
358
+
359
+ item.fullText = `Signal: ${rawTitle}\n${breakPrice ? `BreakPrice: ${breakPrice}\n` : ""}Timestamp: ${item.timestamp}\n\n--- Details ---\n${detailsText}`;
360
+ item.displayTitle = cleanText(rawTitle) + (breakPrice ? ` (BP:${breakPrice})` : "");
361
+ item.attachments = attachments;
362
+ return item;
363
+ };
364
+
365
+ const mergeAndSort = (items, existing, overrideDir, type, preferNew = false) => {
366
+ const combined = preferNew ? [...items, ...existing] : [...items.filter(i => !new Set(existing.map(e => e.id)).has(i.id)), ...existing];
367
+ const unique = Array.from(new Map(combined.map(item => [item.id, item])).values());
368
+ return unique.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)).slice(0, 50).map(i => formatSig(i, overrideDir, type));
369
+ };
370
+
371
+ next.breakout = mergeAndSort(getSigData(0), prev.breakout, null, "breakout");
372
+ next.exhaustion = mergeAndSort([
373
+ ...getSigData(1).map(i => ({ ...i, direction: "bear" })),
374
+ ...getSigData(2).map(i => ({ ...i, direction: "bull" }))
375
+ ], prev.exhaustion, null, "exhaustion");
376
+ next.goldenPit = mergeAndSort(getSigData(3).concat(getSigData(4)), prev.goldenPit, null, "goldenPit");
377
+ next.liquidation = mergeAndSort(getSigData(5), prev.liquidation, null, "liquidation");
378
+
379
+ // 处理 Market Today (本地化情绪)
380
+ const rawMarketToday = getSigData(6);
381
+ const processedMarketToday = [];
382
+ rawMarketToday.forEach(item => {
383
+ item.signals?.forEach(sig => {
384
+ const title = sig.title || "";
385
+ const desc = sig.description || "";
386
+ if (title.includes("Fear") || title.includes("Greed") || desc.includes("Fear And Greed")) {
387
+ const today = desc.match(/Today:\s*(\d+)/)?.[1] || sig.details?.Today || sig.details?.["Today Index"] || "";
388
+ const rawSentiment = desc.match(/Sentiment:\s*([^\n]+)/)?.[1]?.replace("```", "").trim() || sig.details?.Sentiment || "";
389
+
390
+ // 本地化情绪字符串
391
+ let sentiment = rawSentiment;
392
+ if (getLang() === "zh") {
393
+ const sMap = { "Extreme Fear": "SENTIMENT_EXTREME_FEAR", "Fear": "SENTIMENT_FEAR", "Neutral": "SENTIMENT_NEUTRAL", "Greed": "SENTIMENT_GREED", "Extreme Greed": "SENTIMENT_EXTREME_GREED" };
394
+ sentiment = t(sMap[rawSentiment] || rawSentiment);
395
+ }
396
+
397
+ const suffix = today ? `: ${today} (${sentiment})` : "";
398
+ const lines = desc.split("\n").filter(l => !l.trim().startsWith("http"));
399
+ processedMarketToday.push({
400
+ ...item,
401
+ id: `${item.id}-fg`,
402
+ timestamp: item.timestamp,
403
+ signals: [{ ...sig, title: `${t("LABEL_FEAR_GREED")}${suffix}`, description: lines.join("\n").trim() }]
404
+ });
405
+ }
406
+ });
407
+ });
408
+ next.marketToday = mergeAndSort(processedMarketToday, prev.marketToday, null, "marketToday", true);
409
+
410
+ // 解锁内容分拆
411
+ const rawTokenUnlock = getSigData(7);
412
+ const processedTokenUnlock = [];
413
+ rawTokenUnlock.forEach(item => {
414
+ const sig = item.signals?.[0];
415
+ if (!sig?.description) { processedTokenUnlock.push(item); return; }
416
+
417
+ if (typeof sig.description === "object" && sig.description !== null) {
418
+ const entries = Object.entries(sig.description);
419
+ if (entries.length === 0) { processedTokenUnlock.push(item); return; }
420
+ entries.forEach(([label, value], idx) => {
421
+ processedTokenUnlock.push({
422
+ ...item,
423
+ id: `${item.id}-${idx}`,
424
+ signals: [{
425
+ ...sig,
426
+ title: `Unlock: ${label.split("\n")[0].trim()}`,
427
+ description: `${label}\n${typeof value === "string" ? value.replace(/```/g, "") : JSON.stringify(value)}`
428
+ }]
429
+ });
430
+ });
431
+ } else if (typeof sig.description === "string") {
432
+ const parts = sig.description.split(/\[\d+\]\s+Token:/g);
433
+ if (parts.length <= 1) { processedTokenUnlock.push(item); return; }
434
+ parts.forEach((p, idx) => {
435
+ if (idx === 0 || !p.trim()) return;
436
+ const tokenName = p.trim().split("\n")[0].trim();
437
+ processedTokenUnlock.push({ ...item, id: `${item.id}-${idx}`, signals: [{ ...sig, title: `Unlock: ${tokenName}`, description: `Token: ${tokenName}\n${p.trim()}` }] });
438
+ });
439
+ } else {
440
+ processedTokenUnlock.push(item);
441
+ }
442
+ });
443
+ next.tokenUnlock = mergeAndSort(processedTokenUnlock, prev.tokenUnlock, null, "tokenUnlock", true);
444
+
445
+ return next;
446
+ });
447
+ setStatusInfo({ time: new Date().toLocaleTimeString(), mem: memUsage, error: null });
448
+ if (dataTimerRef.current) clearTimeout(dataTimerRef.current);
449
+ dataTimerRef.current = setTimeout(refreshData, 60000);
450
+ } catch (error) {
451
+ if (error.status === 429) {
452
+ const secMatch = error.message.match(/(\d+)\s+seconds/);
453
+ const minMatch = error.message.match(/(\d+)\s+minutes/);
454
+ let delay = 60000;
455
+ if (secMatch) delay = parseInt(secMatch[1]) * 1000 + 2000;
456
+ else if (minMatch) delay = parseInt(minMatch[1]) * 60000 + 2000;
457
+ setStatusInfo(prev => ({ ...prev, error: `Rate Limited (Retry in ${Math.round(delay / 1000)}s)` }));
458
+ if (dataTimerRef.current) clearTimeout(dataTimerRef.current);
459
+ dataTimerRef.current = setTimeout(refreshData, delay);
460
+ } else {
461
+ setStatusInfo(prev => ({ ...prev, error: `Err: ${error.message.substring(0, 20)}` }));
462
+ if (dataTimerRef.current) clearTimeout(dataTimerRef.current);
463
+ dataTimerRef.current = setTimeout(refreshData, 60000);
464
+ }
465
+ }
466
+ setLoading(false);
467
+ };
468
+
469
+ useEffect(() => {
470
+ refreshData();
471
+ return () => { if (dataTimerRef.current) clearTimeout(dataTimerRef.current); };
472
+ }, []);
473
+
474
+ useInput((input, key) => {
475
+ if (dialog) return; // 弹窗时由 DetailDialog 处理
476
+ if (input === "q") { exit(); process.exit(0); }
477
+ if (key.tab) {
478
+ if (key.shift) setFocusIdx(f => (f - 1 + PANELS.length) % PANELS.length);
479
+ else setFocusIdx(f => (f + 1) % PANELS.length);
480
+ }
481
+ });
482
+
483
+ const openDetail = (type, idx) => {
484
+ const item = caches[type]?.[idx];
485
+ if (!item) return;
486
+ const labels = {
487
+ article: t("TYPE_ARTICLE"), breaking: t("TYPE_NEWS"), onchain: t("TYPE_ONCHAIN"), report: t("TYPE_REPORT"),
488
+ breakout: t("LABEL_BREAKOUT"), exhaustion: t("LABEL_EXHAUSTION"), goldenPit: t("LABEL_GOLDEN_PIT"), liquidation: t("LABEL_LIQUIDATION"),
489
+ marketToday: t("LABEL_MARKET_TODAY"), tokenUnlock: t("LABEL_TOKEN_UNLOCK")
490
+ };
491
+ setDialog({ data: item, category: labels[type] });
492
+ };
493
+
494
+ if (loading) return h(LoadingScreen, null);
495
+
496
+ if (authError) return h(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%" },
497
+ h(Text, { color: "red", bold: true }, `!! ${t("ERR_AUTH_FAILED")} !!`),
498
+ h(Text, null, t("RECONFIG_TIP")),
499
+ h(Text, { color: "yellow" }, t("GET_API_KEY_LINK")),
500
+ h(Text, { color: "cyan" }, "[Press Q to Quit]")
501
+ );
502
+
503
+ const newsTypes = ["article", "breaking", "onchain", "report"];
504
+ const newsLabels = { article: t("TYPE_ARTICLE"), breaking: t("TYPE_NEWS"), onchain: t("TYPE_ONCHAIN"), report: t("TYPE_REPORT") };
505
+
506
+ const signalTypes = ["breakout", "exhaustion", "goldenPit", "liquidation"];
507
+ const signalLabels = { breakout: t("LABEL_BREAKOUT"), exhaustion: t("LABEL_EXHAUSTION"), goldenPit: t("LABEL_GOLDEN_PIT"), liquidation: t("LABEL_LIQUIDATION") };
508
+
509
+ const infoTypes = ["marketToday", "tokenUnlock"];
510
+ const infoLabels = { marketToday: t("LABEL_MARKET_TODAY"), tokenUnlock: t("LABEL_TOKEN_UNLOCK") };
511
+
512
+ const termRows = process.stdout.rows || 40;
513
+ // Calculate visible rows for panels (Available height / panels per column)
514
+ const panelVisible = Math.max(2, Math.floor((termRows - 6) / 4) - 2);
515
+ const infoPanelVisible = Math.max(2, Math.floor((termRows - 10) / 2) - 2);
516
+
517
+ if (dialog) return h(DetailDialog, { data: dialog.data, category: dialog.category, onClose: () => setDialog(null) });
518
+
519
+ return h(Box, { flexDirection: "column", height: termRows },
520
+ h(Box, { flexGrow: 1, flexDirection: "row" },
521
+ // Left: News (4)
522
+ h(Box, { flexDirection: "column", width: "33%" },
523
+ ...newsTypes.map((type, i) =>
524
+ h(PanelList, {
525
+ key: type, label: newsLabels[type], items: caches[type],
526
+ focused: focusIdx === i, onSelect: (idx) => openDetail(type, idx),
527
+ maxVisible: panelVisible
528
+ })
529
+ )
530
+ ),
531
+ // Middle: Signals (4)
532
+ h(Box, { flexDirection: "column", width: "34%" },
533
+ ...signalTypes.map((type, i) =>
534
+ h(PanelList, {
535
+ key: type, label: signalLabels[type], items: caches[type],
536
+ focused: focusIdx === i + 4, onSelect: (idx) => openDetail(type, idx),
537
+ maxVisible: panelVisible
538
+ })
539
+ )
540
+ ),
541
+ // Right: Info (3)
542
+ h(Box, { flexDirection: "column", width: "33%" },
543
+ h(ClockPanel, { focused: focusIdx === 8 }),
544
+ ...infoTypes.map((type, i) =>
545
+ h(PanelList, {
546
+ key: type, label: infoLabels[type], items: caches[type],
547
+ focused: focusIdx === i + 9, onSelect: (idx) => openDetail(type, idx),
548
+ maxVisible: infoPanelVisible
549
+ })
550
+ )
551
+ )
552
+ ),
553
+ h(Box, { borderStyle: "single", borderColor: "red", height: 3, paddingX: 1 },
554
+ ...gradientText("MethodAlgo TUI", [255, 60, 60], [255, 255, 255]),
555
+ h(Text, { color: "gray" }, " | "),
556
+ h(Text, { color: "white" }, "Status: "),
557
+ h(Spinner, { color: "green" }),
558
+ h(Text, { color: "green" }, " Running"),
559
+ statusInfo.error ? h(Text, { color: "red", bold: true }, ` [${statusInfo.error}]`) : null,
560
+ h(Text, { color: "gray" }, " | "),
561
+ h(Text, { color: "white" }, statusInfo.time),
562
+ h(Text, { color: "gray" }, " | "),
563
+ h(Text, { color: "cyan" }, `${statusInfo.mem} MB`),
564
+ h(Text, { color: "gray" }, " | "),
565
+ h(Text, { color: "yellow" }, t("TUI_HINTS"))
566
+ )
567
+ );
568
+ };
569
+
570
+ // ── Commander 指令 ─────────────────────────────────────────
571
+ const dashboardCmd = new Command("dashboard")
572
+ .description(t("DASHBOARD_DESC"))
573
+ .addHelpText("after", `\n${t("LABEL_EXAMPLE")}\n $ ${t("DASHBOARD_EXAMPLE")}\n`)
574
+ .alias("top")
575
+ .action(() => {
576
+ render(h(Dashboard, null), { patchConsole: false });
577
+ });
578
+
579
+ export default dashboardCmd;
@@ -0,0 +1,13 @@
1
+ import { Command } from "commander";
2
+ import chalk from "chalk";
3
+ import configManager from "../utils/config-manager.js";
4
+ import { t } from "../utils/i18n.js";
5
+
6
+ const logoutCmd = new Command("logout")
7
+ .description(t("LOGOUT_DESC"))
8
+ .action(() => {
9
+ configManager.set("apiKey", "");
10
+ console.log(chalk.green(`\n✅ ${t("LOGOUT_SUCCESS")}`));
11
+ });
12
+
13
+ export default logoutCmd;