diff --git a/static/hub.css b/static/hub.css index 65b04be..b0431ab 100644 --- a/static/hub.css +++ b/static/hub.css @@ -168,8 +168,39 @@ body.page-login .main { font-weight: 600; letter-spacing: 0.04em; } -.overview-bj-clock { +.overview-quotes { margin-left: auto; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + font-variant-numeric: tabular-nums; +} +.overview-quote-row { + display: grid; + grid-template-columns: 2.4em minmax(4.6em, auto) minmax(3.4em, auto) minmax(3.6em, auto); + align-items: center; + column-gap: 8px; + font-size: 0.82rem; + font-weight: 600; + line-height: 1.2; + white-space: nowrap; +} +.overview-quote-sym { color: var(--muted); } +.overview-quote-last, +.overview-quote-chg, +.overview-quote-pct { text-align: right; } +.overview-quote-row.is-up .overview-quote-last, +.overview-quote-row.is-up .overview-quote-chg, +.overview-quote-row.is-up .overview-quote-pct { color: var(--ok); } +.overview-quote-row.is-down .overview-quote-last, +.overview-quote-row.is-down .overview-quote-chg, +.overview-quote-row.is-down .overview-quote-pct { color: var(--bad); } +.overview-quote-row.is-flat .overview-quote-last, +.overview-quote-row.is-flat .overview-quote-chg, +.overview-quote-row.is-flat .overview-quote-pct { color: var(--text); } +.overview-bj-clock { + margin-left: 0; display: inline-flex; align-items: center; flex-wrap: wrap; @@ -236,10 +267,15 @@ body.page-login .main { } @media (max-width: 720px) { .page-head { flex-wrap: wrap; } - .overview-bj-clock { + .overview-quotes { margin-left: 0; order: 3; width: 100%; + } + .overview-bj-clock { + margin-left: 0; + order: 4; + width: 100%; justify-content: flex-start; padding-top: 2px; } diff --git a/static/hub.js b/static/hub.js index b461bb1..c6fb44e 100644 --- a/static/hub.js +++ b/static/hub.js @@ -566,6 +566,127 @@ }); } + function fmtQuotePx(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 >= 1) digits = 2; + else digits = 4; + try { + return v.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + } catch (e) { + return v.toFixed(digits); + } + } + + function fmtQuoteSigned(n, isPct) { + const v = Number(n); + if (!Number.isFinite(v)) return "—"; + const abs = Math.abs(v); + const digits = isPct ? 2 : abs >= 100 ? 1 : 2; + let body; + try { + body = abs.toLocaleString("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + } catch (e) { + body = abs.toFixed(digits); + } + const sign = v > 0 ? "+" : v < 0 ? "-" : ""; + return sign + body + (isPct ? "%" : ""); + } + + function quoteDirClass(n) { + const v = Number(n); + if (!Number.isFinite(v) || v === 0) return "is-flat"; + return v > 0 ? "is-up" : "is-down"; + } + + function snapshotTickers(row) { + const snap = (row && row.snapshot) || {}; + const quotes = snap.quotes && typeof snap.quotes === "object" ? snap.quotes : {}; + const list = quotes.tickers; + return Array.isArray(list) ? list : []; + } + + function tickersHaveLast(tickers) { + return (tickers || []).some(function (t) { + return t && t.last != null && t.last !== "" && Number.isFinite(Number(t.last)); + }); + } + + function pickQuoteSource(list) { + let best = null; + (list || []).forEach(function (row) { + if (!row || !row.online) return; + const tickers = snapshotTickers(row); + if (!tickersHaveLast(tickers)) return; + const mode = String((((row.snapshot || {}).instance || {}).mode) || "").toLowerCase(); + const live = mode === "live" ? 1 : 0; + const pulled = String(row.pulled_at || ((row.snapshot || {}).quotes || {}).updated_at || ""); + const cand = { tickers: tickers, name: row.name || "", live: live, pulled: pulled }; + if (!best) { + best = cand; + return; + } + if (cand.live !== best.live) { + if (cand.live > best.live) best = cand; + return; + } + if (cand.pulled > best.pulled) best = cand; + }); + return best; + } + + function paintOverviewQuotes(list) { + const root = document.getElementById("overview-quotes"); + if (!root) return; + const src = pickQuoteSource(list); + const tickers = src ? src.tickers : []; + ["ETH", "BTC"].forEach(function (base) { + const row = root.querySelector('.overview-quote-row[data-base="' + base + '"]'); + if (!row) return; + let t = null; + for (let i = 0; i < tickers.length; i++) { + if (String((tickers[i] && tickers[i].base) || "").toUpperCase() === base) { + t = tickers[i]; + break; + } + } + row.classList.remove("is-up", "is-down", "is-flat"); + const lastEl = row.querySelector('[data-field="last"]'); + const chgEl = row.querySelector('[data-field="change"]'); + const pctEl = row.querySelector('[data-field="change_pct"]'); + if (!t || t.last == null) { + row.classList.add("is-flat"); + if (lastEl) lastEl.textContent = "—"; + if (chgEl) chgEl.textContent = "—"; + if (pctEl) pctEl.textContent = "—"; + return; + } + row.classList.add(quoteDirClass(t.change)); + if (lastEl) lastEl.textContent = fmtQuotePx(t.last); + if (chgEl) chgEl.textContent = fmtQuoteSigned(t.change, false); + if (pctEl) pctEl.textContent = fmtQuoteSigned(t.change_pct, true); + }); + if (src && src.name) { + root.title = + "来自 " + + src.name + + (src.live ? " · live" : " · sim") + + " · 相对北京 08:00"; + } else { + root.title = "暂无在线客户端报价 · 相对北京 08:00"; + } + } + async function refresh() { const status = document.getElementById("overview-status"); const grid = document.getElementById("overview-grid"); @@ -577,6 +698,7 @@ if (!d.ok) throw new Error(d.msg || "失败"); const list = sortInstancesByMode(d.instances || []); renderOverviewSummary(list); + paintOverviewQuotes(list); if (!list.length) { grid.innerHTML = '
暂无启用实例,请先到「实例」添加.
'; @@ -611,6 +733,7 @@ box.hidden = true; box.innerHTML = ""; } + paintOverviewQuotes([]); } } diff --git a/templates/base.html b/templates/base.html index d44ebe4..5933f42 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@