diff --git a/static/hub.css b/static/hub.css index b0431ab..ac278bd 100644 --- a/static/hub.css +++ b/static/hub.css @@ -52,47 +52,129 @@ a:hover { color: #7dd3fc; } position: relative; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - gap: 14px; - min-height: 118px; - padding: 36px 88px 20px; + align-items: stretch; + justify-content: flex-start; + gap: 10px; + min-height: 0; + padding: 18px 28px 14px; border-bottom: 1px solid var(--line); background: rgba(8, 14, 26, 0.75); backdrop-filter: blur(12px); } .topbar-brand { - font-family: var(--font-display); - font-size: clamp(1.55rem, 2.1vw, 2.05rem); + align-self: center; + font-family: var(--font); + font-size: 1.75rem; font-weight: 600; - letter-spacing: 0.38em; - text-indent: 0.38em; - color: #f4f8ff; - line-height: 1.15; + letter-spacing: 0; + text-indent: 0; + color: #dbe4ff; + line-height: 1.25; + text-align: center; } .topbar-brand:hover { color: #ffffff; } +.topbar-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 10px 12px; +} .topbar-nav { + grid-column: 2; display: flex; justify-content: center; flex-wrap: wrap; - gap: 6px 28px; + gap: 8px; } .topbar-nav a { - color: var(--muted); - font-size: 1.08rem; - font-weight: 600; - letter-spacing: 0.08em; - padding: 4px 0; + color: #8fc8ff; + font-size: 0.92rem; + font-weight: 500; + letter-spacing: 0; + padding: 6px 10px; + border: 1px solid #304164; + border-radius: 8px; + background: #151a2a; +} +.topbar-nav a:hover { + color: #dbe4ff; + border-color: #3d5278; } .topbar-nav a.active { - color: var(--accent); - text-shadow: 0 0 18px rgba(34, 211, 238, 0.35); + color: #dbe4ff; + background: #2a3f6c; + text-shadow: none; } +.top-nav-spot-tickers { + grid-column: 3; + justify-self: end; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + font-variant-numeric: tabular-nums; +} +.nav-spot-row { + display: grid; + grid-template-columns: 3px 18px minmax(4.6em, auto) minmax(5em, auto) minmax(3.6em, auto) minmax(3.8em, auto); + align-items: center; + column-gap: 7px; + padding: 3px 9px 3px 5px; + border: 1px solid transparent; + border-radius: 6px; + background: rgba(16, 22, 36, 0.72); + line-height: 1.2; + white-space: nowrap; + font-size: 0.88rem; +} +.nav-spot-row:hover { + border-color: #3a4568; +} +.nav-spot-ribbon { + width: 3px; + height: 16px; + border-radius: 1px 2px 2px 1px; + background: #e24b6a; + justify-self: center; +} +.nav-spot-icon { + width: 18px; + height: 18px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.72rem; + font-weight: 700; + color: #fff; +} +.nav-spot-icon--eth { background: #627eea; } +.nav-spot-icon--btc { background: #f2a900; color: #1a1200; } +.nav-spot-sym { + color: #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; } .topbar-logout { position: absolute; - top: 22px; + top: 18px; right: 22px; color: var(--muted); font-size: 0.82rem; @@ -101,18 +183,23 @@ a:hover { color: #7dd3fc; } .topbar-logout:hover { color: #e8f1ff; } +@media (max-width: 1100px) { + .topbar-row { + grid-template-columns: 1fr; + } + .topbar-nav, + .top-nav-spot-tickers { + grid-column: 1; + justify-self: center; + } +} @media (max-width: 720px) { .topbar { min-height: 0; - padding: 24px 16px 14px; - gap: 10px; - } - .topbar-nav a { - font-size: 0.98rem; + padding: 16px 16px 12px; + gap: 8px; } .topbar-brand { - letter-spacing: 0.22em; - text-indent: 0.22em; font-size: 1.35rem; } .topbar-logout { @@ -168,39 +255,8 @@ body.page-login .main { font-weight: 600; letter-spacing: 0.04em; } -.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; + margin-left: auto; display: inline-flex; align-items: center; flex-wrap: wrap; @@ -267,14 +323,9 @@ body.page-login .main { } @media (max-width: 720px) { .page-head { flex-wrap: wrap; } - .overview-quotes { - margin-left: 0; - order: 3; - width: 100%; - } .overview-bj-clock { margin-left: 0; - order: 4; + order: 3; width: 100%; justify-content: flex-start; padding-top: 2px; diff --git a/static/hub.js b/static/hub.js index c6fb44e..bfe612d 100644 --- a/static/hub.js +++ b/static/hub.js @@ -566,127 +566,6 @@ }); } - 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"); @@ -698,7 +577,7 @@ if (!d.ok) throw new Error(d.msg || "失败"); const list = sortInstancesByMode(d.instances || []); renderOverviewSummary(list); - paintOverviewQuotes(list); + if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list); if (!list.length) { grid.innerHTML = '
暂无启用实例,请先到「实例」添加.
'; @@ -733,7 +612,7 @@ box.hidden = true; box.innerHTML = ""; } - paintOverviewQuotes([]); + if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint([]); } } diff --git a/static/hub_quotes.js b/static/hub_quotes.js new file mode 100644 index 0000000..7c90c1a --- /dev/null +++ b/static/hub_quotes.js @@ -0,0 +1,148 @@ +(function (global) { + 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 >= 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 "nav-spot--flat"; + return v > 0 ? "nav-spot--up" : "nav-spot--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 paint(list) { + const root = document.getElementById("nav-spot-tickers"); + if (!root) return; + const src = pickQuoteSource(list); + const tickers = src ? src.tickers : []; + ["ETH", "BTC"].forEach(function (base) { + const row = root.querySelector('.nav-spot-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("nav-spot--up", "nav-spot--down", "nav-spot--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("nav-spot--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"; + } + } + + let timer = null; + let inflight = false; + + async function refresh() { + if (inflight) return; + inflight = true; + try { + const r = await fetch("/api/overview"); + const d = await r.json(); + paint((d && d.instances) || []); + } catch (e) { + paint([]); + } finally { + inflight = false; + } + } + + function boot() { + if (!document.getElementById("nav-spot-tickers")) return; + if (global.HubOverview) return; + refresh(); + if (timer) clearInterval(timer); + timer = setInterval(refresh, 10000); + } + + global.HubQuotes = { boot: boot, paint: paint, refresh: refresh }; +})(window); diff --git a/templates/base.html b/templates/base.html index 5933f42..aa9f370 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,21 +3,41 @@ -