902 lines
35 KiB
JavaScript
902 lines
35 KiB
JavaScript
async function fetchJson(url, options = {}) {
|
||
const response = await fetch(url, {
|
||
credentials: "same-origin",
|
||
cache: "no-store",
|
||
...options,
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
function pretty(data) {
|
||
return JSON.stringify(data, null, 2);
|
||
}
|
||
|
||
function renderItems(containerId, rows, rowRenderer) {
|
||
const target = document.getElementById(containerId);
|
||
if (!target) return;
|
||
target.innerHTML = "";
|
||
rows.forEach((row) => {
|
||
const el = document.createElement("div");
|
||
el.className = "item matrix-list-item";
|
||
el.innerHTML = rowRenderer(row);
|
||
target.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function setInput(id, value) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = value;
|
||
}
|
||
|
||
function setTextareaValue(id, value) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = value != null ? String(value) : "";
|
||
}
|
||
|
||
function getTextareaValue(id) {
|
||
const el = document.getElementById(id);
|
||
return el ? String(el.value || "") : "";
|
||
}
|
||
|
||
function setCheck(id, value) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.checked = !!value;
|
||
}
|
||
|
||
function getInputNumber(id) {
|
||
return Number(document.getElementById(id).value);
|
||
}
|
||
|
||
function getInputText(id) {
|
||
const el = document.getElementById(id);
|
||
return el ? String(el.value || "").trim() : "";
|
||
}
|
||
|
||
function getInputCheck(id) {
|
||
const el = document.getElementById(id);
|
||
return !!(el && el.checked);
|
||
}
|
||
|
||
/** SQLite 常为无时区 naive UTC,补 Z 再解析,避免浏览器当成本地时区 */
|
||
function normalizeUtcIsoString(iso) {
|
||
if (typeof iso !== "string") return iso;
|
||
const s = iso.trim();
|
||
if (/^\d{4}-\d{2}-\d{2}T/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) return `${s}Z`;
|
||
return s;
|
||
}
|
||
|
||
/** ISO 8601 → 北京时间展示 */
|
||
function formatIsoToBeijing(iso) {
|
||
if (!iso || typeof iso !== "string") return "—";
|
||
const t = Date.parse(normalizeUtcIsoString(iso));
|
||
if (Number.isNaN(t)) return iso;
|
||
const s = new Date(t).toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false });
|
||
return s.replace("T", " ");
|
||
}
|
||
|
||
const FUNNEL_WINDOW_LS_KEY = "funnel_display_hours";
|
||
const FUNNEL_WINDOW_DEFAULT = 24;
|
||
const FUNNEL_WINDOW_MIN = 1;
|
||
const FUNNEL_WINDOW_MAX = 168;
|
||
|
||
function getFunnelWindowHours() {
|
||
try {
|
||
const n = Number(localStorage.getItem(FUNNEL_WINDOW_LS_KEY));
|
||
if (Number.isFinite(n) && n >= FUNNEL_WINDOW_MIN && n <= FUNNEL_WINDOW_MAX) {
|
||
return Math.round(n);
|
||
}
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
return FUNNEL_WINDOW_DEFAULT;
|
||
}
|
||
|
||
function setFunnelWindowHours(raw) {
|
||
const n = Math.round(Number(raw));
|
||
const h =
|
||
Number.isFinite(n) && n >= FUNNEL_WINDOW_MIN && n <= FUNNEL_WINDOW_MAX
|
||
? n
|
||
: FUNNEL_WINDOW_DEFAULT;
|
||
try {
|
||
localStorage.setItem(FUNNEL_WINDOW_LS_KEY, String(h));
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
const inp = document.getElementById("funnelWindowHoursInput");
|
||
if (inp) inp.value = String(h);
|
||
return h;
|
||
}
|
||
|
||
function initFunnelWindowControls() {
|
||
const inp = document.getElementById("funnelWindowHoursInput");
|
||
if (inp) inp.value = String(getFunnelWindowHours());
|
||
const btn = document.getElementById("applyFunnelWindowBtn");
|
||
if (btn) {
|
||
btn.addEventListener("click", () => {
|
||
const h = setFunnelWindowHours(getInputNumber("funnelWindowHoursInput"));
|
||
const msg = document.getElementById("funnelWindowMsg");
|
||
if (msg) msg.textContent = `// 展示窗口已设为最近 ${h} 小时(本浏览器记忆)`;
|
||
refresh().catch(console.error);
|
||
});
|
||
}
|
||
}
|
||
|
||
function tickClock() {
|
||
const el = document.getElementById("liveClock");
|
||
if (!el) return;
|
||
const s = new Date().toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false });
|
||
el.textContent = s.replace("T", " ") + " 北京时间 (UTC+8)";
|
||
}
|
||
|
||
function setText(id, text) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = text;
|
||
}
|
||
|
||
function updateHud(status) {
|
||
const st = (status && status.state) || {};
|
||
setText("hudLink", "ONLINE");
|
||
setText("hudCycle", st.last_cycle_status || "—");
|
||
{
|
||
const env = st.btc_env_8h_15m || "—";
|
||
const daily = st.btc_gate_regime && st.btc_gate_regime !== "disabled" ? st.btc_gate_regime : "";
|
||
setText("hudBtc", daily ? `${env} · ${daily}` : env);
|
||
}
|
||
const pool = st.monitoring_pool_count;
|
||
setText("hudPool", pool != null ? String(pool) : "—");
|
||
setText("hudPush", st.pushed_alerts_count != null ? String(st.pushed_alerts_count) : "—");
|
||
const blChip = document.getElementById("symbolBlocklistCountChip");
|
||
if (blChip && st.symbol_blocklist_count != null) {
|
||
blChip.textContent = `${st.symbol_blocklist_count} 条规则`;
|
||
}
|
||
const lastRaw = st.last_cycle_at || st.last_cycle_msg || "—";
|
||
setText("hudLast", st.last_cycle_at ? `${formatIsoToBeijing(st.last_cycle_at)}(北京时间)` : lastRaw);
|
||
|
||
const gemOn = status && status.gemma_enabled;
|
||
const nFun = Array.isArray(st.last_funnel) ? st.last_funnel.length : 0;
|
||
const model = (status && status.gemma_model) || "";
|
||
let gLine = "—";
|
||
if (gemOn === false) {
|
||
gLine = "配置未开启";
|
||
} else if (gemOn === true) {
|
||
const msg = (st.gemma_cycle_msg || "").trim();
|
||
gLine = msg ? `${msg} · 记忆体${nFun}行` : `${model || "ollama"} · 记忆体${nFun}行`;
|
||
}
|
||
setText("hudGemma", gLine);
|
||
}
|
||
|
||
function renderFunnel(items, funnelCtx) {
|
||
const root = document.getElementById("funnelMatrix");
|
||
if (!root) return;
|
||
root.innerHTML = "";
|
||
const ctx = funnelCtx || {};
|
||
const gemmaOn = !!ctx.gemmaEnabled;
|
||
const cycleMsg = String(ctx.cycleMsg || "").trim();
|
||
const lastAt = String(ctx.lastFunnelAt || "").trim();
|
||
if (!items.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "matrix-hint matrix-hint-empty";
|
||
const winH = Number(ctx.windowHours) || FUNNEL_WINDOW_DEFAULT;
|
||
let why =
|
||
`// 暂无漏斗记录:本面板只展示最近 <code>${winH}h</code> 内 <code>source=gemma_funnel</code> 的排序结果(需配置开启且 Ollama 跑完一轮后写入告警表)。`;
|
||
if (!gemmaOn) {
|
||
why += " 当前 <code>gemma.enabled=false</code>,漏斗未运行。";
|
||
} else if (cycleMsg === "funnel_pending") {
|
||
why += " 状态 <strong>funnel_pending</strong>:Gemma 在后台跑,完成后此处会出现卡片。";
|
||
} else if (cycleMsg === "no_funnel_candidates") {
|
||
why += " 本轮扫描无 WATCH/TRIGGER,无漏斗输入。";
|
||
} else if (cycleMsg === "gemma_client_none") {
|
||
why += " 服务未挂载 Gemma 客户端(检查配置并重启)。";
|
||
} else if (cycleMsg && cycleMsg.startsWith("funnel_failed")) {
|
||
why += ` 最近错误:<code>${escapeHtml(cycleMsg)}</code>`;
|
||
} else if (cycleMsg) {
|
||
why += ` 运行时:<code>${escapeHtml(cycleMsg)}</code>`;
|
||
}
|
||
if (lastAt) {
|
||
why += ` <span class="matrix-dim">last_funnel_at: ${escapeHtml(lastAt)}</span>`;
|
||
}
|
||
empty.innerHTML = `<span class='matrix-empty-icon'>◇</span> ${why}`;
|
||
root.appendChild(empty);
|
||
return;
|
||
}
|
||
items.forEach((a) => {
|
||
const d = a.details || {};
|
||
const g = d.gemma || {};
|
||
const comp = Number(d.composite_score || 0);
|
||
const pushed = !!d.priority_push;
|
||
const card = document.createElement("article");
|
||
card.className = "matrix-card" + (pushed ? " hot" : "");
|
||
const vol = (d.programmatic && d.programmatic.est_quote_vol_24h_usdt) || "—";
|
||
const updatedCn = formatIsoToBeijing(a.created_at);
|
||
card.innerHTML = `
|
||
<div class="matrix-card-title">${escapeHtml(a.symbol || "—")}</div>
|
||
<div class="matrix-card-meta time">更新 ${escapeHtml(updatedCn)}(北京时间)</div>
|
||
<div class="matrix-card-meta">
|
||
COMPOSITE <strong>${comp.toFixed(1)}</strong> · P${g.priority || "?"} ·
|
||
结构 ${g.daily_structure || "?"} · 量 ${g.volume_view || "?"} ·
|
||
上方 ${g.upside_space || "?"} · 阻力 ${g.mid_resistance || "?"}
|
||
</div>
|
||
<div class="matrix-bar-wrap"><div class="matrix-bar" style="width:${Math.min(100, comp)}%"></div></div>
|
||
<div class="matrix-card-line">${escapeHtml(g.one_liner || "")}</div>
|
||
<div class="matrix-card-meta">24h 估算 USDT: ${vol} · 图: ${d.image_sent ? "Y" : "N"}</div>
|
||
<span class="matrix-badge ${pushed ? "push" : ""}">${pushed ? "已优先推送" : "未达推送阈值"}</span>
|
||
`;
|
||
root.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function renderDailyReport(payload) {
|
||
const root = document.getElementById("dailyReportBox");
|
||
const meta = document.getElementById("dailyReportMeta");
|
||
if (!root || !meta) return;
|
||
root.innerHTML = "";
|
||
if (!payload || !payload.ready || !payload.report) {
|
||
meta.textContent = `// ${payload && payload.message ? payload.message : "晨报暂不可用"}`;
|
||
root.innerHTML =
|
||
"<div class='matrix-hint matrix-hint-empty'><span class='matrix-empty-icon'>◇</span> // 晨报会按北京时间定时生成,也可点“立即生成”。</div>";
|
||
return;
|
||
}
|
||
const r = payload.report;
|
||
const t = r.text || {};
|
||
const b = r.btc || {};
|
||
const s = r.stats || {};
|
||
const risks = Array.isArray(t.risk_points) ? t.risk_points : [];
|
||
meta.textContent =
|
||
`// 复盘日 ${r.report_day_cn || "—"} | 生成 ${r.generated_at_cn || "—"} | AI ${r.ai_used ? "on" : "fallback"} | BTC ${b.direction || "—"}`;
|
||
const riskHtml = risks.map((x) => `<div>• ${escapeHtml(String(x))}</div>`).join("");
|
||
root.innerHTML = `
|
||
<div class="item matrix-list-item">
|
||
<div class="matrix-row-title"><strong>${escapeHtml(t.headline || "每日晨报")}</strong></div>
|
||
<div>BTC: ${escapeHtml(String(b.direction || "—"))} · 日涨跌 ${escapeHtml(String(b.day_change_pct ?? "—"))}% · SMA20 ${escapeHtml(String(b.sma20 ?? "—"))} · SMA60 ${escapeHtml(String(b.sma60 ?? "—"))}</div>
|
||
<div>统计: WATCH ${escapeHtml(String(s.watch_count ?? 0))} / TRIGGER ${escapeHtml(String(s.trigger_count ?? 0))} / 漏斗优先 ${escapeHtml(String(s.funnel_push_count ?? 0))}</div>
|
||
<div>方向说明: ${escapeHtml(t.btc_explain || "—")}</div>
|
||
<div>总结: ${escapeHtml(t.summary || "—")}</div>
|
||
<div>风险点:</div>
|
||
<div>${riskHtml || "• —"}</div>
|
||
<div>执行提示: ${escapeHtml(t.action_hint || "—")}</div>
|
||
<div class="time">${escapeHtml(String(r.generated_at_cn || "—"))}(北京时间)</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
const SECTION_NAV_STORAGE = "matrix_section_nav_v1";
|
||
|
||
function isNavSectionActive(navId) {
|
||
const sec = document.querySelector(`.matrix-nav-section[data-nav-id="${navId}"]`);
|
||
return !!(sec && sec.classList.contains("is-nav-active"));
|
||
}
|
||
|
||
function initSectionNav() {
|
||
const nav = document.getElementById("sectionNav");
|
||
if (!nav) return;
|
||
const buttons = nav.querySelectorAll(".matrix-nav-item[data-nav-id]");
|
||
const sections = document.querySelectorAll(".matrix-nav-section[data-nav-id]");
|
||
|
||
function activate(navId, opts = {}) {
|
||
const scrollTop = opts.scrollTop !== false;
|
||
buttons.forEach((b) => b.classList.toggle("is-active", b.dataset.navId === navId));
|
||
sections.forEach((s) => s.classList.toggle("is-nav-active", s.dataset.navId === navId));
|
||
try {
|
||
sessionStorage.setItem(SECTION_NAV_STORAGE, navId);
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
if (scrollTop) {
|
||
nav.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
try {
|
||
const url = `#${navId}`;
|
||
if (location.hash !== url) history.replaceState(null, "", url);
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
let initial = "gemma-funnel";
|
||
const hash = (location.hash || "").replace(/^#/, "");
|
||
if (hash && document.querySelector(`.matrix-nav-section[data-nav-id="${hash}"]`)) {
|
||
initial = hash;
|
||
} else {
|
||
try {
|
||
const saved = sessionStorage.getItem(SECTION_NAV_STORAGE);
|
||
if (saved && document.querySelector(`.matrix-nav-section[data-nav-id="${saved}"]`)) {
|
||
initial = saved;
|
||
}
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}
|
||
activate(initial, { scrollTop: false });
|
||
|
||
buttons.forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const id = btn.dataset.navId;
|
||
if (!id) return;
|
||
activate(id);
|
||
if (id === "scan-layers" || id === "runtime-logs" || id === "telemetry") {
|
||
refresh();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
async function loadIntradaySettings() {
|
||
const data = await fetchJson("/api/settings");
|
||
const s = data.intraday_settings || {};
|
||
setInput("rangeHoursInput", s.range_hours ?? 24);
|
||
setInput("rangeMaxPctInput", s.range_max_pct ?? 1.5);
|
||
setInput("volumeSpikeMultInput", s.volume_spike_mult ?? 1.6);
|
||
setInput("volumeLookbackInput", s.volume_lookback_bars ?? 20);
|
||
setInput("breakoutBufferInput", s.breakout_buffer_pct ?? 0.05);
|
||
setInput("stopBufferPctInput", s.stop_buffer_pct ?? 0.2);
|
||
setCheck("pushTimeWindowEnabledInput", s.push_time_window_enabled ?? true);
|
||
const b = data.symbol_blocklist_settings || {};
|
||
setTextareaValue("symbolBlocklistInput", b.symbols_text ?? "");
|
||
const chip = document.getElementById("symbolBlocklistCountChip");
|
||
if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`;
|
||
}
|
||
|
||
async function loadDailyReportSettings() {
|
||
const data = await fetchJson("/api/settings");
|
||
const d = data.daily_report_settings || {};
|
||
setCheck("dailyReportEnabledInput", d.enabled ?? true);
|
||
setInput("dailyReportTimeInput", d.run_time_cn ?? "08:30");
|
||
setCheck("dailyReportPushInput", d.push_wecom ?? true);
|
||
setCheck("dailyReportStartupInput", d.run_on_startup ?? false);
|
||
}
|
||
|
||
async function saveIntradaySettings() {
|
||
const msg = document.getElementById("intradaySaveMsg");
|
||
if (!msg) return;
|
||
msg.textContent = "写入中…";
|
||
try {
|
||
const payload = {
|
||
range_hours: getInputNumber("rangeHoursInput"),
|
||
range_max_pct: getInputNumber("rangeMaxPctInput"),
|
||
volume_spike_mult: getInputNumber("volumeSpikeMultInput"),
|
||
volume_lookback_bars: getInputNumber("volumeLookbackInput"),
|
||
breakout_buffer_pct: getInputNumber("breakoutBufferInput"),
|
||
stop_buffer_pct: getInputNumber("stopBufferPctInput"),
|
||
push_time_window_enabled: getInputCheck("pushTimeWindowEnabledInput"),
|
||
};
|
||
await fetchJson("/api/settings/intraday", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
msg.textContent = "// 已写入,下一轮监控生效";
|
||
} catch (error) {
|
||
msg.textContent = `// 失败 ${error}`;
|
||
}
|
||
}
|
||
|
||
async function saveSymbolBlocklistSettings() {
|
||
const msg = document.getElementById("symbolBlocklistSaveMsg");
|
||
if (msg) msg.textContent = "写入中…";
|
||
try {
|
||
const payload = { symbols_text: getTextareaValue("symbolBlocklistInput") };
|
||
const data = await fetchJson("/api/settings/symbol-blocklist", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
const b = data.symbol_blocklist_settings || {};
|
||
setTextareaValue("symbolBlocklistInput", b.symbols_text ?? "");
|
||
const chip = document.getElementById("symbolBlocklistCountChip");
|
||
if (chip) chip.textContent = `${Number(b.count) || 0} 条规则`;
|
||
if (msg) msg.textContent = "// 已写入,下一轮监控生效";
|
||
} catch (error) {
|
||
if (msg) msg.textContent = `// 失败 ${error}`;
|
||
}
|
||
}
|
||
|
||
async function runDailyReportNow() {
|
||
const meta = document.getElementById("dailyReportMeta");
|
||
if (meta) meta.textContent = "// 手动生成中…";
|
||
try {
|
||
const data = await fetchJson("/api/daily-report/run", { method: "POST" });
|
||
renderDailyReport({ ready: true, report: data.report || null });
|
||
} catch (error) {
|
||
if (meta) meta.textContent = `// 手动生成失败: ${error}`;
|
||
}
|
||
}
|
||
|
||
async function saveDailyReportSettings() {
|
||
const msg = document.getElementById("dailyReportSaveMsg");
|
||
if (msg) msg.textContent = "写入中…";
|
||
try {
|
||
const payload = {
|
||
enabled: getInputCheck("dailyReportEnabledInput"),
|
||
run_time_cn: getInputText("dailyReportTimeInput") || "08:30",
|
||
push_wecom: getInputCheck("dailyReportPushInput"),
|
||
run_on_startup: getInputCheck("dailyReportStartupInput"),
|
||
};
|
||
await fetchJson("/api/settings/daily-report", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (msg) msg.textContent = "// 已写入晨报配置,定时任务已更新";
|
||
} catch (error) {
|
||
if (msg) msg.textContent = `// 写入失败 ${error}`;
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
if (document.visibilityState !== "visible") return;
|
||
const mobileLite = isMobileLite();
|
||
try {
|
||
const funnelWindowH = getFunnelWindowHours();
|
||
const [status, alerts, logs, config, funnel, dailyReport] = await Promise.all([
|
||
fetchJson("/api/status"),
|
||
fetchJson("/api/alerts"),
|
||
fetchJson("/api/logs"),
|
||
fetchJson("/api/config"),
|
||
fetchJson(`/api/funnel?window_hours=${encodeURIComponent(funnelWindowH)}`),
|
||
fetchJson("/api/daily-report"),
|
||
]);
|
||
updateHud(status);
|
||
|
||
const statusPre = document.getElementById("status");
|
||
const cf = document.getElementById("config");
|
||
if (!mobileLite || isNavSectionActive("telemetry")) {
|
||
if (statusPre) {
|
||
const st = pretty(status);
|
||
if (statusPre.textContent !== st) statusPre.textContent = st;
|
||
}
|
||
if (cf) {
|
||
const cfgTxt = pretty(config);
|
||
if (cf.textContent !== cfgTxt) cf.textContent = cfgTxt;
|
||
}
|
||
}
|
||
|
||
const runState = (status && status.state) || {};
|
||
const funnelWindowApplied =
|
||
funnel && funnel.window_hours != null ? Number(funnel.window_hours) : funnelWindowH;
|
||
renderFunnel(funnel.items || [], {
|
||
gemmaEnabled: !!(config.gemma && config.gemma.enabled),
|
||
cycleMsg: runState.gemma_cycle_msg || "",
|
||
lastFunnelAt: runState.last_funnel_at || "",
|
||
windowHours: funnelWindowApplied,
|
||
});
|
||
renderDailyReport(dailyReport);
|
||
loadKeyMonitors();
|
||
try {
|
||
const oe = await fetchJson("/api/order-executors");
|
||
renderOrderExecutors(oe);
|
||
if (document.activeElement !== document.getElementById("oeWebhookSecret")) {
|
||
setCheck("oeGlobalEnabled", !!oe.enabled);
|
||
setInput("oeTimeout", oe.timeout_seconds ?? 15);
|
||
const sec = document.getElementById("oeWebhookSecret");
|
||
if (sec) sec.value = oe.webhook_secret != null ? String(oe.webhook_secret) : "";
|
||
}
|
||
} catch (eOe) {
|
||
console.warn("order executors refresh", eOe);
|
||
}
|
||
|
||
const poll = status.poll_interval_seconds != null ? String(status.poll_interval_seconds) : "?";
|
||
const pullCn = new Date()
|
||
.toLocaleString("sv-SE", { timeZone: "Asia/Shanghai", hour12: false })
|
||
.replace("T", " ");
|
||
const fc = (funnel.items || []).length;
|
||
const lfAt = runState.last_funnel_at ? formatIsoToBeijing(runState.last_funnel_at) : "—";
|
||
const gmsg = runState.gemma_cycle_msg || "—";
|
||
const fm = document.getElementById("funnelMeta");
|
||
if (fm) {
|
||
let line =
|
||
`// 浏览器刚拉完 API:${pullCn} | HUD 的 LAST:上一轮 Gate 扫描整轮结束(可与本行差约 ${poll}s)| ` +
|
||
`矩阵卡片 ${fc} 条:最近 ${funnelWindowApplied}h 内 gemma_funnel(每币最新一条)| 记忆体 last_funnel 更新:${lfAt} | 后轮 gemma:${gmsg}`;
|
||
if (String(gmsg).includes("funnel_ranked=0") && fc > 0) {
|
||
line +=
|
||
` | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过),仍显示 ${funnelWindowApplied}h 内已有卡片。`;
|
||
} else {
|
||
line += ` | 超过 ${funnelWindowApplied}h 的漏斗记录不再显示(库内仍在)。`;
|
||
}
|
||
fm.textContent = line;
|
||
}
|
||
|
||
const allAlerts = alerts.items || [];
|
||
const watchRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "WATCH");
|
||
const triggerRows = allAlerts.filter((a) => (a.details && a.details.signal_level) === "TRIGGER");
|
||
|
||
if (isNavSectionActive("scan-layers")) {
|
||
renderItems("watchAlerts", watchRows, (a) => `
|
||
<div class="matrix-row-title"><strong>${a.symbol}</strong> <span class="matrix-dim">${escapeHtml(a.chain || "")}</span></div>
|
||
<div>级别: ${(a.details && a.details.signal_level) || "N/A"}</div>
|
||
<div>信号: ${(a.trigger_types || []).join(" · ")}</div>
|
||
<div>评分: ${Number(a.score).toFixed(2)}</div>
|
||
<div class="time">${formatIsoToBeijing(a.created_at)}</div>
|
||
`);
|
||
|
||
if (!triggerRows.length) {
|
||
const trig = document.getElementById("triggerAlerts");
|
||
if (trig) {
|
||
trig.innerHTML =
|
||
"<div class='matrix-hint matrix-hint-empty'><span class='matrix-empty-icon'>◇</span> " +
|
||
"// 暂无 TRIGGER:触发层只显示 <code>signal_level=TRIGGER</code> 的告警(通常需横盘后<strong>放量突破</strong>等更严条件)。有 WATCH 不代表已进入 TRIGGER。</div>";
|
||
}
|
||
} else {
|
||
renderItems("triggerAlerts", triggerRows, (a) => `
|
||
<div class="matrix-row-title"><strong>${a.symbol}</strong> <span class="matrix-dim">${escapeHtml(a.chain || "")}</span></div>
|
||
<div>级别: ${(a.details && a.details.signal_level) || "N/A"}</div>
|
||
<div>信号: ${(a.trigger_types || []).join(" · ")}</div>
|
||
<div>推送状态: ${((a.details || {}).strict_push_ok === true) ? "已推送" : "未推送"}</div>
|
||
<div>未推送原因: ${escapeHtml(String(((a.details || {}).push_block_reason || "—")))}</div>
|
||
<div>评分: ${Number(a.score).toFixed(2)}</div>
|
||
<div class="time">${formatIsoToBeijing(a.created_at)}</div>
|
||
`);
|
||
}
|
||
}
|
||
|
||
if (isNavSectionActive("runtime-logs")) {
|
||
renderItems("logs", logs.items || [], (l) => `
|
||
<div><strong class="matrix-log-lvl-${(l.level || "").toLowerCase()}">[${l.level}]</strong> ${escapeHtml(l.message)}</div>
|
||
<div class="time">${formatIsoToBeijing(l.created_at)}</div>
|
||
`);
|
||
}
|
||
} catch (error) {
|
||
console.error("refresh failed", error);
|
||
setText("hudLink", "ERR");
|
||
const fm = document.getElementById("funnelMeta");
|
||
if (fm) fm.textContent = `// 拉取失败(检查登录是否过期、网络): ${error}`;
|
||
}
|
||
}
|
||
|
||
/** 手机 / 触控:关闭重动画与代码雨,减轻闪屏与发热 */
|
||
function isMobileLite() {
|
||
return (
|
||
document.documentElement.classList.contains("matrix-lite") ||
|
||
window.matchMedia("(max-width: 640px), (hover: none) and (pointer: coarse)").matches
|
||
);
|
||
}
|
||
|
||
function applyMobileLiteClass() {
|
||
if (isMobileLite()) {
|
||
document.documentElement.classList.add("matrix-lite");
|
||
document.body.classList.add("matrix-lite");
|
||
}
|
||
}
|
||
|
||
/** 轻量 Canvas 代码雨(仅 dashboard 有 canvas;手机端不启动) */
|
||
function initMatrixRain() {
|
||
applyMobileLiteClass();
|
||
if (isMobileLite()) return;
|
||
const canvas = document.getElementById("matrixRain");
|
||
if (!canvas || !canvas.getContext) return;
|
||
const ctx = canvas.getContext("2d");
|
||
const chars = "01アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモラリルレロ";
|
||
let w = 0;
|
||
let h = 0;
|
||
let columns = [];
|
||
const fontSize = 14;
|
||
|
||
function resize() {
|
||
w = canvas.width = window.innerWidth;
|
||
h = canvas.height = window.innerHeight;
|
||
const maxCols = w < 640 ? 22 : 48;
|
||
const colCount = Math.min(maxCols, Math.ceil(w / fontSize));
|
||
columns = Array.from({ length: colCount }, () => ({
|
||
y: Math.random() * h,
|
||
speed: 0.8 + Math.random() * 2.2,
|
||
head: Math.floor(Math.random() * chars.length),
|
||
}));
|
||
}
|
||
|
||
window.addEventListener("resize", resize);
|
||
resize();
|
||
|
||
function frame() {
|
||
ctx.fillStyle = "rgba(2, 2, 6, 0.12)";
|
||
ctx.fillRect(0, 0, w, h);
|
||
ctx.font = `${fontSize}px ui-monospace, monospace`;
|
||
for (let i = 0; i < columns.length; i++) {
|
||
const col = columns[i];
|
||
const x = i * fontSize;
|
||
const ch = chars[(col.head + Math.floor(col.y / fontSize)) % chars.length];
|
||
const flicker = 0.35 + Math.random() * 0.45;
|
||
ctx.fillStyle = `rgba(0, 255, 200, ${flicker})`;
|
||
ctx.fillText(ch, x, col.y % (h + fontSize));
|
||
col.y += col.speed;
|
||
if (col.y > h + fontSize) col.y = -fontSize * (3 + Math.random() * 8);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
function formatOeLastForward(lf) {
|
||
if (!lf || typeof lf !== "object") return "—";
|
||
const at = lf.at ? formatIsoToBeijing(lf.at) : "—";
|
||
const st = lf.exec_status != null ? String(lf.exec_status) : "—";
|
||
const http = lf.http_status != null ? String(lf.http_status) : "—";
|
||
return `${at} · HTTP ${http} · ${st}`;
|
||
}
|
||
|
||
function renderOrderExecutors(snap) {
|
||
const root = document.getElementById("oeList");
|
||
if (!root) return;
|
||
const rows = (snap && snap.executors) || [];
|
||
if (!rows.length) {
|
||
root.innerHTML =
|
||
"<div class='matrix-hint matrix-hint-empty'>// 尚未添加执行器。单账户填一条 Base URL;多账户对照实验填多条(如 :8090 / :8091)。</div>";
|
||
return;
|
||
}
|
||
root.innerHTML = rows
|
||
.map((ex) => {
|
||
const id = escapeHtml(String(ex.id || ""));
|
||
const en = !!ex.enabled;
|
||
const lf = formatOeLastForward(ex.last_forward);
|
||
return `
|
||
<div class="item matrix-list-item" data-oe-id="${id}">
|
||
<div class="matrix-row-title"><strong>${escapeHtml(ex.name || "—")}</strong>
|
||
<span class="matrix-chip ${en ? "" : "matrix-dim"}">${en ? "启用" : "停用"}</span></div>
|
||
<div class="mono">${escapeHtml(ex.base_url || "—")}</div>
|
||
<div>上次转发: ${escapeHtml(lf)}</div>
|
||
<div class="matrix-form-row matrix-form-row-tight" style="margin-top:8px">
|
||
<button type="button" class="matrix-btn ghost oe-toggle" data-id="${id}" data-enabled="${en ? "0" : "1"}">${en ? "停用" : "启用"}</button>
|
||
<button type="button" class="matrix-btn ghost oe-delete" data-id="${id}">删除</button>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
}
|
||
|
||
async function loadOrderExecutors() {
|
||
const data = await fetchJson("/api/order-executors");
|
||
setCheck("oeGlobalEnabled", !!data.enabled);
|
||
setInput("oeTimeout", data.timeout_seconds ?? 15);
|
||
const sec = document.getElementById("oeWebhookSecret");
|
||
if (sec && document.activeElement !== sec) {
|
||
sec.value = data.webhook_secret != null ? String(data.webhook_secret) : "";
|
||
}
|
||
renderOrderExecutors(data);
|
||
}
|
||
|
||
async function saveOrderExecutorsGlobal() {
|
||
const msg = document.getElementById("oeGlobalMsg");
|
||
if (msg) msg.textContent = "保存中…";
|
||
try {
|
||
const payload = {
|
||
enabled: getInputCheck("oeGlobalEnabled"),
|
||
webhook_secret: getInputText("oeWebhookSecret"),
|
||
timeout_seconds: Number(getInputText("oeTimeout") || "15"),
|
||
};
|
||
const data = await fetchJson("/api/order-executors/settings", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (msg) msg.textContent = "// 已保存(改 webhook 后请同步各执行器 config)";
|
||
renderOrderExecutors(data.order_executors || data);
|
||
} catch (error) {
|
||
if (msg) msg.textContent = `// 失败 ${error}`;
|
||
}
|
||
}
|
||
|
||
async function addOrderExecutor() {
|
||
const msg = document.getElementById("oeAddMsg");
|
||
if (msg) msg.textContent = "提交中…";
|
||
try {
|
||
const data = await fetchJson("/api/order-executors", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: getInputText("oeNewName"),
|
||
base_url: getInputText("oeNewUrl"),
|
||
enabled: getInputCheck("oeNewEnabled"),
|
||
}),
|
||
});
|
||
if (msg) msg.textContent = "// 已添加";
|
||
setInput("oeNewName", "");
|
||
setInput("oeNewUrl", "");
|
||
setCheck("oeNewEnabled", true);
|
||
renderOrderExecutors(data.order_executors || data);
|
||
} catch (error) {
|
||
if (msg) msg.textContent = `// 失败 ${error}`;
|
||
}
|
||
}
|
||
|
||
async function patchOrderExecutor(id, body) {
|
||
return fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
function syncKeySlTpFields() {
|
||
const mode = getInputText("keySlTpModeInput") || "standard";
|
||
const tpEl = document.getElementById("keyManualTpInput");
|
||
if (tpEl) tpEl.style.display = mode === "trend_manual" ? "" : "none";
|
||
}
|
||
|
||
function setKeyMonitorActiveCount(n) {
|
||
const el = document.getElementById("keyMonitorActiveCount");
|
||
if (el) el.textContent = String(n);
|
||
}
|
||
|
||
function renderKeyMonitorActiveList(rows) {
|
||
const target = document.getElementById("keyMonitorActive");
|
||
if (!target) return;
|
||
setKeyMonitorActiveCount(rows.length);
|
||
target.innerHTML = "";
|
||
if (!rows.length) {
|
||
target.innerHTML = '<div class="matrix-dim">暂无监控中的关键位</div>';
|
||
return;
|
||
}
|
||
rows.forEach((row) => {
|
||
const prev = row.preview || {};
|
||
const checks = prev.checks || {};
|
||
const gateOk = prev.gate_ok ? "门控通过" : "门控未过";
|
||
const gateClass = prev.gate_ok ? "key-gate-ok" : "key-gate-pending";
|
||
const dir = row.direction === "long" ? "多" : "空";
|
||
const modeLabel = row.sl_tp_mode === "trend_manual" ? "趋势" : "标准";
|
||
const el = document.createElement("div");
|
||
el.className = "item matrix-list-item key-monitor-row";
|
||
el.innerHTML = `
|
||
<div class="key-monitor-row-body">
|
||
<div><strong>${row.symbol}</strong> ${dir} · ${row.monitor_type} · ${modeLabel}</div>
|
||
<div class="matrix-dim">上 ${row.upper} / 下 ${row.lower} · <span class="${gateClass}">${gateOk}</span> · 确认 ${checks.confirm_close != null ? checks.confirm_close : "—"}</div>
|
||
</div>
|
||
<button type="button" class="matrix-btn ghost key-del-btn key-monitor-del" data-id="${row.id}">删除并归档</button>
|
||
`;
|
||
target.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function renderKeyMonitors(data) {
|
||
const rule = document.getElementById("keyMonitorRule");
|
||
if (rule && data.rule_text) rule.textContent = `// ${data.rule_text}`;
|
||
|
||
renderKeyMonitorActiveList(data.active || []);
|
||
|
||
const hist = data.history || [];
|
||
renderItems("keyMonitorHistory", hist.slice(0, 80), (h) => {
|
||
const dir = h.direction === "long" ? "多" : "空";
|
||
const modeLabel = h.sl_tp_mode === "trend_manual" ? "趋势" : "标准";
|
||
return `
|
||
<div><strong>${h.symbol}</strong> ${dir} · ${h.monitor_type} · ${modeLabel}</div>
|
||
<div class="matrix-dim">${h.close_reason} · RR ${h.planned_rr != null ? Number(h.planned_rr).toFixed(2) : "—"} · ${formatIsoToBeijing(h.closed_at)}</div>
|
||
`;
|
||
});
|
||
if (!hist.length) {
|
||
const t = document.getElementById("keyMonitorHistory");
|
||
if (t) t.innerHTML = '<div class="matrix-dim">暂无历史</div>';
|
||
}
|
||
}
|
||
|
||
async function loadKeyMonitors() {
|
||
try {
|
||
const data = await fetchJson("/api/key-monitors");
|
||
renderKeyMonitors(data);
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
async function addKeyMonitor() {
|
||
const msg = document.getElementById("keyMonitorSaveMsg");
|
||
const symbol = getInputText("keySymbolInput");
|
||
const direction = getInputText("keyDirectionInput");
|
||
const monitor_type = getInputText("keyMonitorTypeInput");
|
||
const sl_tp_mode = getInputText("keySlTpModeInput") || "standard";
|
||
if (!symbol || !direction) {
|
||
if (msg) msg.textContent = "// 请填写币种与方向";
|
||
return;
|
||
}
|
||
const body = {
|
||
symbol,
|
||
direction,
|
||
monitor_type,
|
||
sl_tp_mode,
|
||
upper: getInputNumber("keyUpperInput"),
|
||
lower: getInputNumber("keyLowerInput"),
|
||
breakeven_enabled: getInputCheck("keyBreakevenInput"),
|
||
};
|
||
if (sl_tp_mode === "trend_manual") {
|
||
body.manual_take_profit = getInputNumber("keyManualTpInput");
|
||
}
|
||
try {
|
||
await fetchJson("/api/key-monitors", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (msg) msg.textContent = "// 已添加,开始 5m 门控轮询";
|
||
loadKeyMonitors();
|
||
} catch (error) {
|
||
if (msg) msg.textContent = `// 失败 ${error}`;
|
||
}
|
||
}
|
||
|
||
function wireKeyMonitorPanel() {
|
||
const modeSel = document.getElementById("keySlTpModeInput");
|
||
if (modeSel) modeSel.addEventListener("change", syncKeySlTpFields);
|
||
syncKeySlTpFields();
|
||
const addBtn = document.getElementById("keyAddBtn");
|
||
if (addBtn) addBtn.addEventListener("click", addKeyMonitor);
|
||
const active = document.getElementById("keyMonitorActive");
|
||
if (active) {
|
||
active.addEventListener("click", async (ev) => {
|
||
const btn = ev.target.closest && ev.target.closest(".key-del-btn");
|
||
if (!btn) return;
|
||
const id = btn.getAttribute("data-id");
|
||
if (!id || !confirm("删除该关键位?将写入历史。")) return;
|
||
try {
|
||
await fetchJson(`/api/key-monitors/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
loadKeyMonitors();
|
||
} catch (error) {
|
||
alert(String(error));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function wireOrderExecutorsPanel() {
|
||
const saveG = document.getElementById("oeSaveGlobalBtn");
|
||
if (saveG) saveG.addEventListener("click", saveOrderExecutorsGlobal);
|
||
const addB = document.getElementById("oeAddBtn");
|
||
if (addB) addB.addEventListener("click", addOrderExecutor);
|
||
const list = document.getElementById("oeList");
|
||
if (list) {
|
||
list.addEventListener("click", async (ev) => {
|
||
const tgl = ev.target.closest && ev.target.closest(".oe-toggle");
|
||
const del = ev.target.closest && ev.target.closest(".oe-delete");
|
||
const id = (tgl || del) && (tgl || del).getAttribute("data-id");
|
||
if (!id) return;
|
||
try {
|
||
if (tgl) {
|
||
const en = tgl.getAttribute("data-enabled") === "1";
|
||
const data = await patchOrderExecutor(id, { enabled: en });
|
||
renderOrderExecutors(data.order_executors || data);
|
||
} else if (del) {
|
||
if (!confirm("确认从扫描端移除该执行器?(不会停止执行器进程)")) return;
|
||
const data = await fetchJson(`/api/order-executors/${encodeURIComponent(id)}`, {
|
||
method: "DELETE",
|
||
});
|
||
renderOrderExecutors(data.order_executors || data);
|
||
}
|
||
} catch (error) {
|
||
alert(String(error));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const saveBtn = document.getElementById("saveIntradayBtn");
|
||
if (saveBtn) saveBtn.addEventListener("click", saveIntradaySettings);
|
||
const saveBlocklistBtn = document.getElementById("saveSymbolBlocklistBtn");
|
||
if (saveBlocklistBtn) saveBlocklistBtn.addEventListener("click", saveSymbolBlocklistSettings);
|
||
const runDailyBtn = document.getElementById("runDailyReportBtn");
|
||
if (runDailyBtn) runDailyBtn.addEventListener("click", runDailyReportNow);
|
||
const saveDailyBtn = document.getElementById("saveDailyReportBtn");
|
||
if (saveDailyBtn) saveDailyBtn.addEventListener("click", saveDailyReportSettings);
|
||
loadIntradaySettings().catch(console.error);
|
||
loadDailyReportSettings().catch(console.error);
|
||
wireKeyMonitorPanel();
|
||
wireOrderExecutorsPanel();
|
||
loadOrderExecutors().catch(console.error);
|
||
loadKeyMonitors().catch(console.error);
|
||
tickClock();
|
||
setInterval(tickClock, 1000);
|
||
initMatrixRain();
|
||
initFunnelWindowControls();
|
||
initSectionNav();
|
||
refresh();
|
||
const REFRESH_MS = isMobileLite() ? 10000 : 4000;
|
||
setInterval(refresh, REFRESH_MS);
|
||
document.addEventListener("visibilitychange", () => {
|
||
if (document.visibilityState === "visible") refresh();
|
||
});
|