首次上传
This commit is contained in:
@@ -0,0 +1,620 @@
|
||||
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", " ");
|
||||
}
|
||||
|
||||
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";
|
||||
let why =
|
||||
"// 暂无漏斗记录:本面板只展示 <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) || "—";
|
||||
card.innerHTML = `
|
||||
<div class="matrix-card-title">${a.symbol}</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>
|
||||
`;
|
||||
}
|
||||
|
||||
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() {
|
||||
try {
|
||||
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"),
|
||||
fetchJson("/api/daily-report"),
|
||||
]);
|
||||
updateHud(status);
|
||||
|
||||
const statusPre = document.getElementById("status");
|
||||
const cf = document.getElementById("config");
|
||||
if (statusPre) statusPre.textContent = pretty(status);
|
||||
if (cf) cf.textContent = pretty(config);
|
||||
|
||||
const runState = (status && status.state) || {};
|
||||
renderFunnel(funnel.items || [], {
|
||||
gemmaEnabled: !!(config.gemma && config.gemma.enabled),
|
||||
cycleMsg: runState.gemma_cycle_msg || "",
|
||||
lastFunnelAt: runState.last_funnel_at || "",
|
||||
});
|
||||
renderDailyReport(dailyReport);
|
||||
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} 条:来自告警库「每币最新一条」| 记忆体 last_funnel 更新:${lfAt} | 后轮 gemma:${gmsg}`;
|
||||
if (String(gmsg).includes("funnel_ranked=0") && fc > 0) {
|
||||
line +=
|
||||
" | 说明:本轮后台漏斗未写入新排名(常见:4h 内同一币已跑过 FUNNEL-GEMMA 被跳过、或候选在取日线/Ollama 前被滤掉),卡片仍是历史结果,不是前端卡死。";
|
||||
} else {
|
||||
line += " | 若文案长期不变=近期没有新的 gemma_funnel 入库。";
|
||||
}
|
||||
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");
|
||||
|
||||
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>
|
||||
`);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 轻量 Canvas 代码雨(仅 dashboard 有 canvas) */
|
||||
function initMatrixRain() {
|
||||
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 colCount = Math.min(48, 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 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);
|
||||
wireOrderExecutorsPanel();
|
||||
loadOrderExecutors().catch(console.error);
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
initMatrixRain();
|
||||
refresh();
|
||||
setInterval(refresh, 4000);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") refresh();
|
||||
});
|
||||
Reference in New Issue
Block a user