From b804bd19a7a6768a4fbb5f01d2577101690e1646 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 15 Jun 2026 17:27:31 +0800 Subject: [PATCH] =?UTF-8?q?K=E7=BA=BF=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E3=80=81=E5=9B=BE=E8=A1=A8=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E4=BA=A4=E6=98=93=E8=AE=B0=E5=BD=95=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 kline_store 优先读本地库;修复加载中遮挡、支持缩放与交易时段刷新;修复交易记录操作列被裁切。 Co-authored-by: Cursor --- app.py | 4 +- kline_chart.py | 54 +++++++- kline_store.py | 170 ++++++++++++++++++++++++ static/js/market.js | 293 ++++++++++++++++++++++++++++++++--------- templates/base.html | 37 ++++-- templates/market.html | 23 +++- templates/records.html | 4 +- 7 files changed, 505 insertions(+), 80 deletions(-) create mode 100644 kline_store.py diff --git a/app.py b/app.py index 64b2f2e..6e2f25c 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,7 @@ from fee_specs import ( from fee_sync import sync_fees_from_akshare from contract_profile import get_contract_profile from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache +from kline_store import ensure_kline_tables from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label @@ -303,6 +304,7 @@ def init_db(): (key TEXT PRIMARY KEY, data_json TEXT NOT NULL, updated_at TEXT NOT NULL)''') + ensure_kline_tables(conn) conn.commit() conn.close() @@ -1321,7 +1323,7 @@ def api_kline(): if not symbol: return jsonify({"error": "请提供合约代码"}), 400 try: - data = fetch_market_klines(symbol, period) + data = fetch_market_klines(symbol, period, DB_PATH) except Exception as exc: app.logger.warning("kline api failed: %s", exc) return jsonify({"error": str(exc)}), 500 diff --git a/kline_chart.py b/kline_chart.py index 21c411a..3fbfb2c 100644 --- a/kline_chart.py +++ b/kline_chart.py @@ -3,6 +3,7 @@ import json import logging import os import re +import sqlite3 from datetime import datetime from typing import Optional from zoneinfo import ZoneInfo @@ -10,6 +11,7 @@ from zoneinfo import ZoneInfo import requests from symbols import ths_to_codes +from kline_store import ensure_kline_tables, get_cached_entry, save_bars logger = logging.getLogger(__name__) TZ = ZoneInfo("Asia/Shanghai") @@ -213,14 +215,60 @@ def bars_to_api(bars: list) -> list[dict]: return result -def fetch_market_klines(symbol: str, period: str) -> dict: +def fetch_market_klines(symbol: str, period: str, db_path: Optional[str] = None) -> dict: chart_sym = ths_to_sina_chart_symbol(symbol) p = (period or "15m").lower() if p == "timeshare": chart_type = "line" else: chart_type = "candle" - bars = fetch_sina_klines(symbol, p) + + bars: list = [] + source = "remote" + cached_at = None + + if db_path and chart_sym: + try: + conn = sqlite3.connect(db_path) + cached = get_cached_entry(conn, chart_sym, p) + conn.close() + if cached and cached.get("fresh"): + bars = cached["bars"] + source = "local" + cached_at = cached.get("updated_at") + except Exception as exc: + logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc) + + if not bars: + remote_bars = fetch_sina_klines(symbol, p) + if remote_bars: + bars = remote_bars + source = "remote" + if db_path and chart_sym: + try: + conn = sqlite3.connect(db_path) + ensure_kline_tables(conn) + save_bars(conn, chart_sym, p, remote_bars) + meta = conn.execute( + "SELECT updated_at FROM kline_meta WHERE chart_symbol=? AND period=?", + (chart_sym, p), + ).fetchone() + conn.close() + cached_at = meta[0] if meta else None + except Exception as exc: + logger.warning("kline cache write failed %s %s: %s", chart_sym, p, exc) + elif db_path and chart_sym: + try: + conn = sqlite3.connect(db_path) + cached = get_cached_entry(conn, chart_sym, p) + conn.close() + if cached and cached.get("bars"): + bars = cached["bars"] + source = "local" + cached_at = cached.get("updated_at") + except Exception as exc: + logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc) + return { "symbol": symbol, "chart_symbol": chart_sym, @@ -228,6 +276,8 @@ def fetch_market_klines(symbol: str, period: str) -> dict: "chart_type": chart_type, "count": len(bars), "bars": bars_to_api(bars), + "source": source, + "cached_at": cached_at, } diff --git a/kline_store.py b/kline_store.py new file mode 100644 index 0000000..3321c0f --- /dev/null +++ b/kline_store.py @@ -0,0 +1,170 @@ +"""K 线本地 SQLite 缓存。""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta +from typing import Optional +from zoneinfo import ZoneInfo + +TZ = ZoneInfo("Asia/Shanghai") + +REFRESH_SECONDS = { + "timeshare": 30, + "1m": 30, + "2m": 30, + "5m": 60, + "15m": 60, + "1h": 120, + "2h": 120, + "4h": 180, + "d": 300, + "w": 600, +} + + +def ensure_kline_tables(conn: sqlite3.Connection) -> None: + conn.execute( + """CREATE TABLE IF NOT EXISTS kline_bars ( + chart_symbol TEXT NOT NULL, + period TEXT NOT NULL, + bar_time TEXT NOT NULL, + open REAL NOT NULL, + high REAL NOT NULL, + low REAL NOT NULL, + close REAL NOT NULL, + volume REAL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (chart_symbol, period, bar_time) + )""" + ) + conn.execute( + """CREATE TABLE IF NOT EXISTS kline_meta ( + chart_symbol TEXT NOT NULL, + period TEXT NOT NULL, + bar_count INTEGER DEFAULT 0, + last_bar_time TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (chart_symbol, period) + )""" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period " + "ON kline_bars(chart_symbol, period, bar_time)" + ) + conn.commit() + + +def _parse_updated_at(value: str) -> Optional[datetime]: + if not value: + return None + try: + return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ) + except ValueError: + return None + + +def is_cache_fresh(period: str, updated_at: str) -> bool: + dt = _parse_updated_at(updated_at) + if not dt: + return False + ttl = REFRESH_SECONDS.get((period or "").lower(), 60) + return datetime.now(TZ) - dt < timedelta(seconds=ttl) + + +def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]: + rows = conn.execute( + """SELECT bar_time, open, high, low, close, volume + FROM kline_bars + WHERE chart_symbol=? AND period=? + ORDER BY bar_time ASC""", + (chart_symbol, period), + ).fetchall() + return [ + { + "d": row[0], + "o": float(row[1]), + "h": float(row[2]), + "l": float(row[3]), + "c": float(row[4]), + "v": float(row[5] or 0), + } + for row in rows + ] + + +def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]: + row = conn.execute( + "SELECT bar_count, last_bar_time, updated_at FROM kline_meta " + "WHERE chart_symbol=? AND period=?", + (chart_symbol, period), + ).fetchone() + if not row: + return None + return { + "bar_count": row[0], + "last_bar_time": row[1], + "updated_at": row[2], + } + + +def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int: + if not bars: + return 0 + ensure_kline_tables(conn) + now = datetime.now(TZ).isoformat(timespec="seconds") + for bar in bars: + conn.execute( + """INSERT INTO kline_bars + (chart_symbol, period, bar_time, open, high, low, close, volume, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET + open=excluded.open, + high=excluded.high, + low=excluded.low, + close=excluded.close, + volume=excluded.volume, + updated_at=excluded.updated_at""", + ( + chart_symbol, + period, + str(bar["d"]), + float(bar["o"]), + float(bar["h"]), + float(bar["l"]), + float(bar["c"]), + float(bar.get("v") or 0), + now, + ), + ) + last_time = str(bars[-1]["d"]) + conn.execute( + """INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at) + VALUES (?,?,?,?,?) + ON CONFLICT(chart_symbol, period) DO UPDATE SET + bar_count=excluded.bar_count, + last_bar_time=excluded.last_bar_time, + updated_at=excluded.updated_at""", + (chart_symbol, period, len(bars), last_time, now), + ) + conn.commit() + return len(bars) + + +def get_cached_entry( + conn: sqlite3.Connection, + chart_symbol: str, + period: str, +) -> Optional[dict]: + if not chart_symbol: + return None + ensure_kline_tables(conn) + meta = load_meta(conn, chart_symbol, period) + bars = load_bars(conn, chart_symbol, period) + if not bars: + return None + updated_at = meta["updated_at"] if meta else "" + return { + "bars": bars, + "updated_at": updated_at, + "fresh": is_cache_fresh(period, updated_at), + } diff --git a/static/js/market.js b/static/js/market.js index f3b0022..ccc2bf1 100644 --- a/static/js/market.js +++ b/static/js/market.js @@ -5,6 +5,11 @@ var chart = null; var currentPeriod = '15m'; var quoteTimer = null; + var klineTimer = null; + var lastData = null; + var klineLoading = false; + + var FAST_PERIODS = ['timeshare', '1m', '2m', '5m', '15m', '1h', '2h', '4h']; function getSymbol() { var hidden = document.getElementById('market-symbol-hidden'); @@ -33,9 +38,40 @@ down: dark ? '#ff6b7a' : '#dc2626', line: dark ? '#4cc2ff' : '#2563eb', area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)', + slider: dark ? '#1e2640' : '#cbd5e1', + sliderFill: dark ? '#4cc2ff' : '#2563eb', }; } + function isTradingSession() { + var d = new Date(); + var wd = d.getDay(); + if (wd === 0) return false; + if (wd === 6 && d.getHours() < 21) return false; + var t = d.getHours() * 60 + d.getMinutes(); + function inRange(sh, sm, eh, em) { + return t >= sh * 60 + sm && t < eh * 60 + em; + } + if (inRange(9, 0, 11, 30)) return true; + if (inRange(13, 30, 15, 0)) return true; + if (inRange(21, 0, 24, 0)) return true; + if (inRange(0, 0, 2, 30)) return true; + return false; + } + + function klinePollMs() { + if (!isTradingSession()) return 0; + if (currentPeriod === 'timeshare' || FAST_PERIODS.indexOf(currentPeriod) >= 0) { + return 1000; + } + if (currentPeriod === 'd' || currentPeriod === 'w') return 30000; + return 5000; + } + + function quotePollMs() { + return isTradingSession() ? 1000 : 10000; + } + function initChart() { if (!chartEl || !window.echarts) return; chart = echarts.init(chartEl); @@ -45,42 +81,90 @@ document.addEventListener('click', function (e) { if (e.target.closest('[data-theme-pick]')) { setTimeout(function () { - if (chart && lastData) renderChart(lastData); + if (chart && lastData) renderChart(lastData, true); }, 100); } }); } - var lastData = null; + function getDataZoom(c, preserve) { + var defStart = currentPeriod === 'timeshare' ? 60 : 75; + var zoom = [ + { + type: 'inside', + xAxisIndex: [0, 1], + start: defStart, + end: 100, + zoomOnMouseWheel: true, + moveOnMouseMove: true, + moveOnMouseWheel: false, + }, + { + type: 'slider', + xAxisIndex: [0, 1], + start: defStart, + end: 100, + height: 22, + bottom: 4, + borderColor: c.grid, + backgroundColor: c.bg, + fillerColor: c.area, + handleStyle: { color: c.sliderFill }, + dataBackground: { + lineStyle: { color: c.grid }, + areaStyle: { color: c.area }, + }, + textStyle: { color: c.text, fontSize: 10 }, + }, + ]; + if (preserve && chart) { + var opt = chart.getOption(); + if (opt && opt.dataZoom) { + opt.dataZoom.forEach(function (z, i) { + if (zoom[i] && z.start != null && z.end != null) { + zoom[i].start = z.start; + zoom[i].end = z.end; + } + }); + } + } + return zoom; + } - function renderChart(data) { + function renderChart(data, preserveZoom) { if (!chart) return; lastData = data; var c = themeColors(); var bars = data.bars || []; var times = bars.map(function (b) { return b.time; }); var isLine = data.chart_type === 'line' || data.period === 'timeshare'; + var dataZoom = getDataZoom(c, preserveZoom); + var grids = [ + { left: 56, right: 20, top: 44, height: '50%' }, + { left: 56, right: 20, top: '66%', height: '14%' }, + ]; + + var base = { + backgroundColor: c.bg, + animation: false, + tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, + axisPointer: { link: [{ xAxisIndex: 'all' }] }, + dataZoom: dataZoom, + grid: grids, + xAxis: [ + { type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } }, + { type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } }, + ], + yAxis: [ + { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } }, + { scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 }, + ], + }; if (isLine) { var closes = bars.map(function (b) { return b.close; }); var vols = bars.map(function (b) { return b.volume; }); - chart.setOption({ - backgroundColor: c.bg, - animation: false, - tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, - axisPointer: { link: [{ xAxisIndex: 'all' }] }, - grid: [ - { left: 56, right: 16, top: 40, height: '58%' }, - { left: 56, right: 16, top: '72%', height: '18%' }, - ], - xAxis: [ - { type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } }, - { type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } }, - ], - yAxis: [ - { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } }, - { scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 }, - ], + chart.setOption(Object.assign(base, { series: [ { name: '价格', @@ -102,19 +186,17 @@ yAxisIndex: 1, }, ], - }, true); + }), true); } else { var candle = bars.map(function (b) { return [b.open, b.close, b.low, b.high]; }); - var vols = bars.map(function (b, i) { + var vols = bars.map(function (b) { var up = b.close >= b.open; return { value: b.volume, itemStyle: { color: up ? c.up : c.down, opacity: 0.65 }, }; }); - chart.setOption({ - backgroundColor: c.bg, - animation: false, + chart.setOption(Object.assign(base, { tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, @@ -132,19 +214,6 @@ ].join('
'); }, }, - axisPointer: { link: [{ xAxisIndex: 'all' }] }, - grid: [ - { left: 56, right: 16, top: 40, height: '58%' }, - { left: 56, right: 16, top: '72%', height: '18%' }, - ], - xAxis: [ - { type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } }, - { type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } }, - ], - yAxis: [ - { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } }, - { scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 }, - ], series: [ { name: 'K线', @@ -167,11 +236,13 @@ yAxisIndex: 1, }, ], - }, true); + }), true); } var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period); - chart.setOption({ title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } } }); + chart.setOption({ + title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } }, + }); } function periodLabel(key) { @@ -182,26 +253,61 @@ return key; } + function hideEmptyOverlay() { + if (emptyEl) { + emptyEl.style.display = ''; + } + if (wrapEl) wrapEl.classList.add('has-data'); + } + + function showEmptyOverlay(text) { + if (emptyEl) { + emptyEl.textContent = text; + emptyEl.style.display = 'flex'; + } + if (wrapEl) wrapEl.classList.remove('has-data'); + } + function setLoading(on) { var btn = document.getElementById('market-load-btn'); if (btn) { btn.disabled = on; btn.textContent = on ? '加载中…' : '查看'; } - if (emptyEl && on) { - emptyEl.textContent = '加载中…'; - emptyEl.style.display = 'flex'; + if (on) { + showEmptyOverlay('加载中…'); + } else if (lastData) { + hideEmptyOverlay(); } } - function loadKline() { - var symbol = getSymbol(); - if (!symbol) { - alert('请先选择或输入合约代码'); + function updateRefreshHint() { + var el = document.getElementById('market-refresh-hint'); + if (!el) return; + if (!getSymbol()) { + el.textContent = ''; return; } - setLoading(true); - if (wrapEl) wrapEl.classList.remove('has-data'); + if (isTradingSession()) { + var ms = klinePollMs(); + var src = lastData && lastData.source === 'local' ? ' · 本地缓存' : ''; + el.textContent = ms === 1000 + ? '交易中 · 1秒刷新' + src + : '交易中 · 自动刷新' + src; + } else { + el.textContent = '非交易时段 · 暂停高频刷新'; + } + } + + function loadKline(silent) { + var symbol = getSymbol(); + if (!symbol) { + if (!silent) alert('请先选择或输入合约代码'); + return; + } + if (klineLoading) return; + klineLoading = true; + if (!silent) setLoading(true); var url = '/api/kline?symbol=' + encodeURIComponent(symbol) + '&period=' + encodeURIComponent(currentPeriod); fetch(url) @@ -209,22 +315,23 @@ return r.json().then(function (j) { return { ok: r.ok, data: j }; }); }) .then(function (res) { - if (!res.ok) { - throw new Error(res.data.error || '加载失败'); - } - if (wrapEl) wrapEl.classList.add('has-data'); - renderChart(res.data); + if (!res.ok) throw new Error(res.data.error || '加载失败'); + hideEmptyOverlay(); + renderChart(res.data, silent); updateQuoteMeta(res.data); - startQuotePoll(); + updateRefreshHint(); + if (!quoteTimer) startQuotePoll(); + if (!klineTimer) startKlinePoll(); }) .catch(function (err) { - if (emptyEl) { - emptyEl.textContent = err.message || '加载失败'; - emptyEl.style.display = 'flex'; + if (!silent) { + showEmptyOverlay(err.message || '加载失败'); } - if (wrapEl) wrapEl.classList.remove('has-data'); }) - .finally(function () { setLoading(false); }); + .finally(function () { + klineLoading = false; + if (!silent) setLoading(false); + }); } function updateQuoteMeta(data) { @@ -261,7 +368,48 @@ function startQuotePoll() { if (quoteTimer) clearInterval(quoteTimer); loadQuote(); - quoteTimer = setInterval(loadQuote, 5000); + var ms = quotePollMs(); + if (ms > 0) quoteTimer = setInterval(loadQuote, ms); + } + + function startKlinePoll() { + if (klineTimer) clearInterval(klineTimer); + var ms = klinePollMs(); + if (ms > 0 && getSymbol()) { + klineTimer = setInterval(function () { + loadKline(true); + updateRefreshHint(); + }, ms); + } + } + + function restartPollers() { + startQuotePoll(); + startKlinePoll(); + updateRefreshHint(); + } + + function shiftDataZoom(delta) { + if (!chart) return; + var opt = chart.getOption(); + if (!opt || !opt.dataZoom || !opt.dataZoom.length) return; + var z = opt.dataZoom[0]; + var span = (z.end - z.start) || 20; + var newSpan = Math.max(5, Math.min(100, span + delta)); + var center = (z.start + z.end) / 2; + var start = Math.max(0, center - newSpan / 2); + var end = Math.min(100, center + newSpan / 2); + if (end - start < newSpan) { + if (start === 0) end = newSpan; + else start = end - newSpan; + } + chart.dispatchAction({ type: 'dataZoom', start: start, end: end }); + } + + function resetDataZoom() { + if (!chart) return; + var start = currentPeriod === 'timeshare' ? 60 : 75; + chart.dispatchAction({ type: 'dataZoom', start: start, end: 100 }); } function bindPeriodTabs() { @@ -273,25 +421,42 @@ tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); }); btn.classList.add('active'); currentPeriod = btn.getAttribute('data-period') || '15m'; - if (getSymbol()) loadKline(); + restartPollers(); + if (getSymbol()) loadKline(false); }); } + function bindZoomButtons() { + var zoomIn = document.getElementById('chart-zoom-in'); + var zoomOut = document.getElementById('chart-zoom-out'); + var zoomReset = document.getElementById('chart-zoom-reset'); + if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-12); }); + if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(12); }); + if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); + } + document.addEventListener('DOMContentLoaded', function () { initChart(); bindPeriodTabs(); + bindZoomButtons(); var active = document.querySelector('.period-tab.active'); if (active) currentPeriod = active.getAttribute('data-period') || '15m'; var loadBtn = document.getElementById('market-load-btn'); - if (loadBtn) loadBtn.addEventListener('click', loadKline); + if (loadBtn) loadBtn.addEventListener('click', function () { + restartPollers(); + loadKline(false); + }); var hidden = document.getElementById('market-symbol-hidden'); var input = document.getElementById('market-symbol-input'); if (hidden && hidden.value) { if (input && !input.value) input.value = hidden.value; - loadKline(); + restartPollers(); + loadKline(false); + } else { + updateRefreshHint(); } }); })(); diff --git a/templates/base.html b/templates/base.html index 0745614..498e86c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -388,21 +388,38 @@ .pos-del{font-size:.75rem;padding:.35rem .65rem} .trade-toolbar{display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap} .trade-switch-label{ - display:flex;align-items:center;gap:.35rem; - font-size:.68rem;color:var(--text-muted); - white-space:nowrap;margin-bottom:.65rem;cursor:pointer; + display:flex;align-items:center;gap:.4rem; + font-size:.78rem;color:var(--text-muted); + white-space:normal;margin-bottom:.65rem;cursor:pointer; + line-height:1.45;max-width:100%; } - .trade-switch-label span{line-height:1} - .trade-switch-label input{flex-shrink:0} - .trade-table-wrap{overflow-x:auto} - .trade-table{font-size:.8rem} - .trade-table th{font-size:.75rem;padding:.55rem .45rem} - .trade-table td{padding:.45rem .4rem;vertical-align:middle} + .trade-switch-label span{line-height:1.45;color:var(--text-muted)} + .trade-switch-label input{flex-shrink:0;width:auto} + .trade-table-wrap{ + overflow:auto; + max-height:420px; + width:100%; + -webkit-overflow-scrolling:touch; + border-radius:10px; + border:1px solid var(--table-border); + background:var(--card-inner); + } + .trade-table{font-size:.8rem;width:max-content;min-width:100%;table-layout:auto} + .trade-table th{font-size:.75rem;padding:.55rem .45rem;white-space:nowrap;background:var(--card-inner)} + .trade-table td{padding:.45rem .4rem;vertical-align:middle;white-space:nowrap;background:var(--card-inner)} + .trade-table th:last-child, + .trade-table td:last-child{ + position:sticky;right:0;z-index:3; + box-shadow:-6px 0 10px rgba(0,0,0,.08); + } + .trade-table thead th:last-child{z-index:4} .trade-table input,.trade-table select{ padding:.35rem .45rem;font-size:.78rem;border-radius:6px;width:100%;min-width:0; } .trade-table .cell-readonly{color:var(--text-primary)} - .trade-actions{display:flex;gap:.35rem;flex-wrap:wrap} + .records-trade-card{overflow:visible} + .records-trade-card .card-body{overflow:visible} + .trade-actions{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;min-width:148px} .trade-actions a,.trade-actions button{font-size:.72rem;padding:.3rem .55rem;border-radius:6px;text-decoration:none;border:none;cursor:pointer} .btn-fill{background:var(--dir-bg);color:var(--accent)} .btn-verify{background:var(--nav-active);color:#fff} diff --git a/templates/market.html b/templates/market.html index 064279e..4ede7ef 100644 --- a/templates/market.html +++ b/templates/market.html @@ -26,11 +26,19 @@ +
+
+ + + +
+ +
请选择合约并点击「查看」
-

数据来源:新浪财经。分时图为当日分钟走势;2分/2小时/周线由基础周期合成。

+

数据来源:新浪财经。支持滚轮/拖拽缩放 K 线;交易时段内行情与 K 线约 1 秒刷新。