From 08029ccb1538607abbcdcd0206e5490a64edef32 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 17 Aug 2026 11:42:19 +0800 Subject: [PATCH] Add BTC/ETH spot tickers on nav, anchored to Beijing 08:00 open. Co-authored-by: Cursor --- app.py | 23 +++ lib/common/static/instance_page.css | 4 +- lib/common/static/instance_theme.css | 159 ++++++++++++++++++- lib/common/static/instance_theme.js | 3 +- lib/common/static/nav_spot_tickers.js | 120 ++++++++++++++ lib/instance/templates/embed_shell.html | 10 +- lib/instance/templates/index.html | 10 +- lib/instance/templates/nav_spot_tickers.html | 19 +++ lib/market/nav_spot_tickers_lib.py | 138 ++++++++++++++++ 9 files changed, 472 insertions(+), 14 deletions(-) create mode 100644 lib/common/static/nav_spot_tickers.js create mode 100644 lib/instance/templates/nav_spot_tickers.html create mode 100644 lib/market/nav_spot_tickers_lib.py diff --git a/app.py b/app.py index 5b5d29e..2cbe323 100644 --- a/app.py +++ b/app.py @@ -5660,6 +5660,29 @@ def api_settings_open_guard(): ) +@app.route("/api/nav_spot_tickers") +@login_required +def api_nav_spot_tickers(): + """BTC/ETH 现货最新价,相对北京时间 08:00(OKX 日K开盘)涨跌.""" + try: + from lib.market.nav_spot_tickers_lib import build_nav_spot_tickers + + tickers = build_nav_spot_tickers( + exchange, + reset_hour=TRADING_DAY_RESET_HOUR, + now_fn=lambda: datetime.now(APP_TZ), + ) + return jsonify( + { + "ok": True, + "updated_at": app_now_str(), + "tickers": tickers, + } + ) + except Exception as e: + return jsonify({"ok": False, "msg": str(e) or "行情读取失败", "tickers": []}), 502 + + @app.route("/api/price_snapshot") @login_required def api_price_snapshot(): diff --git a/lib/common/static/instance_page.css b/lib/common/static/instance_page.css index f5c6e8e..c98e625 100644 --- a/lib/common/static/instance_page.css +++ b/lib/common/static/instance_page.css @@ -10,7 +10,9 @@ .header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25} .exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em} .header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center} - .top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px} + .top-nav{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:10px 12px;margin-bottom:12px} + .top-nav-links{grid-column:2;display:flex;gap:8px;flex-wrap:wrap;justify-content:center;min-width:0} + .top-nav-spot-tickers{grid-column:3;justify-self:end;display:flex;flex-direction:column;gap:3px;min-width:0} .top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none} .top-nav a.active{background:#2a3f6c;color:#dbe4ff} .stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch} diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index d4e0f8c..ccec979 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -54,21 +54,43 @@ html[data-theme="light"] { .top-nav { display: flex !important; + flex-direction: column !important; flex-wrap: nowrap !important; justify-content: flex-start !important; - align-items: stretch; - overflow-x: auto !important; - overflow-y: hidden; + align-items: stretch !important; + overflow: visible !important; width: 100%; max-width: 100%; + gap: 8px !important; + margin-bottom: 12px !important; + padding: 2px 2px 6px; + } + + .top-nav-links { + display: flex; + flex-wrap: nowrap; + overflow-x: auto !important; + overflow-y: hidden; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; scrollbar-width: none; - gap: 6px !important; - margin-bottom: 12px !important; - padding: 2px 2px 6px; + gap: 6px; scroll-padding-inline: 10px; touch-action: pan-x; + min-width: 0; + } + + .top-nav-links::-webkit-scrollbar { + display: none; + } + + .top-nav-spot-tickers { + justify-self: stretch; + align-self: stretch; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; } .top-nav::-webkit-scrollbar { @@ -633,6 +655,131 @@ html[data-theme="light"] .theme-toggle-btn.is-active { font-size: 0.82rem; } +/* 导航右侧 BTC/ETH 现货(相对北京 08:00 开盘) */ +.top-nav-links { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: center; + min-width: 0; +} + +.top-nav-spot-tickers { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + font-variant-numeric: tabular-nums; +} + +.nav-spot-row { + display: grid; + grid-template-columns: 3px 16px minmax(4.2em, auto) minmax(4.5em, auto) minmax(3.2em, auto) minmax(3.4em, auto); + align-items: center; + column-gap: 6px; + padding: 2px 8px 2px 4px; + border: 1px solid transparent; + border-radius: 6px; + background: rgba(16, 22, 36, 0.72); + line-height: 1.15; + white-space: nowrap; + font-size: 0.72rem; +} + +.nav-spot-row:hover { + border-color: #3a4568; +} + +.nav-spot-ribbon { + width: 3px; + height: 14px; + border-radius: 1px 2px 2px 1px; + background: #e24b6a; + justify-self: center; +} + +.nav-spot-icon { + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.62rem; + font-weight: 700; + color: #fff; +} + +.nav-spot-icon--eth { + background: #627eea; +} + +.nav-spot-icon--btc { + background: #f2a900; + color: #1a1200; +} + +.nav-spot-sym { + color: var(--inst-text, #e8ecff); + font-weight: 600; + letter-spacing: 0.01em; +} + +.nav-spot-last, +.nav-spot-chg, +.nav-spot-pct { + text-align: right; + font-weight: 600; +} + +.nav-spot--up .nav-spot-last, +.nav-spot--up .nav-spot-chg, +.nav-spot--up .nav-spot-pct { + color: #2dd4a8; +} + +.nav-spot--down .nav-spot-last, +.nav-spot--down .nav-spot-chg, +.nav-spot--down .nav-spot-pct { + color: #ff6b7a; +} + +.nav-spot--flat .nav-spot-last, +.nav-spot--flat .nav-spot-chg, +.nav-spot--flat .nav-spot-pct { + color: #9aa3bf; +} + +html[data-theme="light"] .nav-spot-row { + background: rgba(255, 255, 255, 0.85); + border-color: #d5e0ec; +} + +html[data-theme="light"] .nav-spot-row:hover { + border-color: #9bb4cc; +} + +html[data-theme="light"] .nav-spot--up .nav-spot-last, +html[data-theme="light"] .nav-spot--up .nav-spot-chg, +html[data-theme="light"] .nav-spot--up .nav-spot-pct { + color: #0a8f6c; +} + +html[data-theme="light"] .nav-spot--down .nav-spot-last, +html[data-theme="light"] .nav-spot--down .nav-spot-chg, +html[data-theme="light"] .nav-spot--down .nav-spot-pct { + color: #d12b3a; +} + +@media (max-width: 1100px) { + .nav-spot-row { + grid-template-columns: 3px 14px minmax(3.6em, auto) minmax(3.8em, auto) minmax(2.8em, auto) minmax(3em, auto); + column-gap: 4px; + font-size: 0.66rem; + padding: 2px 6px 2px 3px; + } +} + .instance-header-stats-wrap { margin-top: 12px; padding-top: 12px; diff --git a/lib/common/static/instance_theme.js b/lib/common/static/instance_theme.js index c4b70af..670b50b 100644 --- a/lib/common/static/instance_theme.js +++ b/lib/common/static/instance_theme.js @@ -262,7 +262,8 @@ const mq = window.matchMedia("(max-width: 720px)"); function scrollActiveTab(nav) { - const active = nav.querySelector("a.active"); + const scroller = nav.querySelector(".top-nav-links") || nav; + const active = scroller.querySelector("a.active"); if (!active) return; requestAnimationFrame(() => { try { diff --git a/lib/common/static/nav_spot_tickers.js b/lib/common/static/nav_spot_tickers.js new file mode 100644 index 0000000..d934837 --- /dev/null +++ b/lib/common/static/nav_spot_tickers.js @@ -0,0 +1,120 @@ +/** + * 导航栏 BTC/ETH 现货报价:相对北京 08:00 开盘涨跌. + */ +(function (global) { + "use strict"; + + const DEFAULT_POLL_MS = 5000; + let timer = null; + let inflight = false; + + function pollMs() { + const raw = + (document.body && document.body.getAttribute("data-price-refresh-ms")) || ""; + const n = Number(raw); + return Number.isFinite(n) && n >= 2000 ? n : DEFAULT_POLL_MS; + } + + function fmtPrice(n) { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + const abs = Math.abs(v); + let digits = 2; + if (abs >= 10000) digits = 1; + else if (abs >= 1000) digits = 2; + else if (abs >= 100) digits = 2; + else if (abs >= 1) digits = 3; + else digits = 4; + try { + return v.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + } catch (_) { + return v.toFixed(digits); + } + } + + function fmtSigned(n, isPct) { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + const abs = Math.abs(v); + let digits = isPct ? 2 : abs >= 100 ? 1 : 2; + if (!isPct && abs < 1) digits = 3; + const body = abs.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + const sign = v > 0 ? "" : v < 0 ? "-" : ""; + return sign + body + (isPct ? "%" : ""); + } + + function dirClass(n) { + const v = Number(n); + if (!Number.isFinite(v) || v === 0) return "nav-spot--flat"; + return v > 0 ? "nav-spot--up" : "nav-spot--down"; + } + + function paintRow(row, t) { + if (!row || !t) return; + const lastEl = row.querySelector('[data-field="last"]'); + const chgEl = row.querySelector('[data-field="change"]'); + const pctEl = row.querySelector('[data-field="change_pct"]'); + const dir = dirClass(t.change); + row.classList.remove("nav-spot--up", "nav-spot--down", "nav-spot--flat"); + row.classList.add(dir); + if (lastEl) lastEl.textContent = fmtPrice(t.last); + if (chgEl) chgEl.textContent = fmtSigned(t.change, false); + if (pctEl) pctEl.textContent = fmtSigned(t.change_pct, true); + const openHint = + t.open != null + ? "开盘 " + fmtPrice(t.open) + " (" + (t.anchor || "BJ 08:00") + ")" + : t.anchor || "BJ 08:00"; + row.title = (t.symbol || "") + " " + openHint; + } + + function apply(data) { + const root = document.getElementById("nav-spot-tickers"); + if (!root) return; + const list = (data && data.tickers) || []; + list.forEach(function (t) { + const base = (t.base || "").toUpperCase(); + if (!base) return; + const row = root.querySelector('.nav-spot-row[data-base="' + base + '"]'); + paintRow(row, t); + }); + } + + function refresh() { + const root = document.getElementById("nav-spot-tickers"); + if (!root || inflight) return; + inflight = true; + fetch("/api/nav_spot_tickers") + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data && data.ok !== false) apply(data); + }) + .catch(function () {}) + .finally(function () { + inflight = false; + }); + } + + function start() { + if (!document.getElementById("nav-spot-tickers")) return; + refresh(); + if (timer) clearInterval(timer); + timer = setInterval(refresh, pollMs()); + } + + global.refreshNavSpotTickers = refresh; + global.startNavSpotTickers = start; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } +})(window); diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index e9b57c8..5e8f605 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -3,12 +3,12 @@ - + - - + + @@ -38,6 +38,7 @@

加密货币|OKX 期权与对冲

@@ -146,6 +149,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj {% include 'embed_boot_scripts.html' %} + + @@ -20,8 +20,8 @@ {{ pwa_app_name }} - - + + 加密货币|OKX 期权与对冲
+ + {% include 'nav_spot_tickers.html' %}
{% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} @@ -1827,6 +1830,7 @@ tickOrderHoldDurations(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); +