K线本地缓存、图表交互优化与交易记录表格修复

新增 kline_store 优先读本地库;修复加载中遮挡、支持缩放与交易时段刷新;修复交易记录操作列被裁切。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-15 17:27:31 +08:00
parent a9f4e2b1a5
commit b804bd19a7
7 changed files with 505 additions and 80 deletions
+3 -1
View File
@@ -30,6 +30,7 @@ from fee_specs import (
from fee_sync import sync_fees_from_akshare from fee_sync import sync_fees_from_akshare
from contract_profile import get_contract_profile from contract_profile import get_contract_profile
from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache 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 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 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, (key TEXT PRIMARY KEY,
data_json TEXT NOT NULL, data_json TEXT NOT NULL,
updated_at TEXT NOT NULL)''') updated_at TEXT NOT NULL)''')
ensure_kline_tables(conn)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1321,7 +1323,7 @@ def api_kline():
if not symbol: if not symbol:
return jsonify({"error": "请提供合约代码"}), 400 return jsonify({"error": "请提供合约代码"}), 400
try: try:
data = fetch_market_klines(symbol, period) data = fetch_market_klines(symbol, period, DB_PATH)
except Exception as exc: except Exception as exc:
app.logger.warning("kline api failed: %s", exc) app.logger.warning("kline api failed: %s", exc)
return jsonify({"error": str(exc)}), 500 return jsonify({"error": str(exc)}), 500
+52 -2
View File
@@ -3,6 +3,7 @@ import json
import logging import logging
import os import os
import re import re
import sqlite3
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -10,6 +11,7 @@ from zoneinfo import ZoneInfo
import requests import requests
from symbols import ths_to_codes from symbols import ths_to_codes
from kline_store import ensure_kline_tables, get_cached_entry, save_bars
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TZ = ZoneInfo("Asia/Shanghai") TZ = ZoneInfo("Asia/Shanghai")
@@ -213,14 +215,60 @@ def bars_to_api(bars: list) -> list[dict]:
return result 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) chart_sym = ths_to_sina_chart_symbol(symbol)
p = (period or "15m").lower() p = (period or "15m").lower()
if p == "timeshare": if p == "timeshare":
chart_type = "line" chart_type = "line"
else: else:
chart_type = "candle" 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 { return {
"symbol": symbol, "symbol": symbol,
"chart_symbol": chart_sym, "chart_symbol": chart_sym,
@@ -228,6 +276,8 @@ def fetch_market_klines(symbol: str, period: str) -> dict:
"chart_type": chart_type, "chart_type": chart_type,
"count": len(bars), "count": len(bars),
"bars": bars_to_api(bars), "bars": bars_to_api(bars),
"source": source,
"cached_at": cached_at,
} }
+170
View File
@@ -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),
}
+220 -55
View File
@@ -5,6 +5,11 @@
var chart = null; var chart = null;
var currentPeriod = '15m'; var currentPeriod = '15m';
var quoteTimer = null; 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() { function getSymbol() {
var hidden = document.getElementById('market-symbol-hidden'); var hidden = document.getElementById('market-symbol-hidden');
@@ -33,9 +38,40 @@
down: dark ? '#ff6b7a' : '#dc2626', down: dark ? '#ff6b7a' : '#dc2626',
line: dark ? '#4cc2ff' : '#2563eb', line: dark ? '#4cc2ff' : '#2563eb',
area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)', 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() { function initChart() {
if (!chartEl || !window.echarts) return; if (!chartEl || !window.echarts) return;
chart = echarts.init(chartEl); chart = echarts.init(chartEl);
@@ -45,34 +81,76 @@
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
if (e.target.closest('[data-theme-pick]')) { if (e.target.closest('[data-theme-pick]')) {
setTimeout(function () { setTimeout(function () {
if (chart && lastData) renderChart(lastData); if (chart && lastData) renderChart(lastData, true);
}, 100); }, 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; if (!chart) return;
lastData = data; lastData = data;
var c = themeColors(); var c = themeColors();
var bars = data.bars || []; var bars = data.bars || [];
var times = bars.map(function (b) { return b.time; }); var times = bars.map(function (b) { return b.time; });
var isLine = data.chart_type === 'line' || data.period === 'timeshare'; 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%' },
];
if (isLine) { var base = {
var closes = bars.map(function (b) { return b.close; });
var vols = bars.map(function (b) { return b.volume; });
chart.setOption({
backgroundColor: c.bg, backgroundColor: c.bg,
animation: false, animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
axisPointer: { link: [{ xAxisIndex: 'all' }] }, axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: [ dataZoom: dataZoom,
{ left: 56, right: 16, top: 40, height: '58%' }, grid: grids,
{ left: 56, right: 16, top: '72%', height: '18%' },
],
xAxis: [ xAxis: [
{ type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } }, { 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 } } }, { type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } },
@@ -81,6 +159,12 @@
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } }, { 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 }, { 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(Object.assign(base, {
series: [ series: [
{ {
name: '价格', name: '价格',
@@ -102,19 +186,17 @@
yAxisIndex: 1, yAxisIndex: 1,
}, },
], ],
}, true); }), true);
} else { } else {
var candle = bars.map(function (b) { return [b.open, b.close, b.low, b.high]; }); 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; var up = b.close >= b.open;
return { return {
value: b.volume, value: b.volume,
itemStyle: { color: up ? c.up : c.down, opacity: 0.65 }, itemStyle: { color: up ? c.up : c.down, opacity: 0.65 },
}; };
}); });
chart.setOption({ chart.setOption(Object.assign(base, {
backgroundColor: c.bg,
animation: false,
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
axisPointer: { type: 'cross' }, axisPointer: { type: 'cross' },
@@ -132,19 +214,6 @@
].join('<br/>'); ].join('<br/>');
}, },
}, },
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: [ series: [
{ {
name: 'K线', name: 'K线',
@@ -167,11 +236,13 @@
yAxisIndex: 1, yAxisIndex: 1,
}, },
], ],
}, true); }), true);
} }
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period); 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) { function periodLabel(key) {
@@ -182,26 +253,61 @@
return key; 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) { function setLoading(on) {
var btn = document.getElementById('market-load-btn'); var btn = document.getElementById('market-load-btn');
if (btn) { if (btn) {
btn.disabled = on; btn.disabled = on;
btn.textContent = on ? '加载中…' : '查看'; btn.textContent = on ? '加载中…' : '查看';
} }
if (emptyEl && on) { if (on) {
emptyEl.textContent = '加载中…'; showEmptyOverlay('加载中…');
emptyEl.style.display = 'flex'; } else if (lastData) {
hideEmptyOverlay();
} }
} }
function loadKline() { function updateRefreshHint() {
var symbol = getSymbol(); var el = document.getElementById('market-refresh-hint');
if (!symbol) { if (!el) return;
alert('请先选择或输入合约代码'); if (!getSymbol()) {
el.textContent = '';
return; return;
} }
setLoading(true); if (isTradingSession()) {
if (wrapEl) wrapEl.classList.remove('has-data'); 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); var url = '/api/kline?symbol=' + encodeURIComponent(symbol) + '&period=' + encodeURIComponent(currentPeriod);
fetch(url) fetch(url)
@@ -209,22 +315,23 @@
return r.json().then(function (j) { return { ok: r.ok, data: j }; }); return r.json().then(function (j) { return { ok: r.ok, data: j }; });
}) })
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) throw new Error(res.data.error || '加载失败');
throw new Error(res.data.error || '加载失败'); hideEmptyOverlay();
} renderChart(res.data, silent);
if (wrapEl) wrapEl.classList.add('has-data');
renderChart(res.data);
updateQuoteMeta(res.data); updateQuoteMeta(res.data);
startQuotePoll(); updateRefreshHint();
if (!quoteTimer) startQuotePoll();
if (!klineTimer) startKlinePoll();
}) })
.catch(function (err) { .catch(function (err) {
if (emptyEl) { if (!silent) {
emptyEl.textContent = err.message || '加载失败'; showEmptyOverlay(err.message || '加载失败');
emptyEl.style.display = 'flex';
} }
if (wrapEl) wrapEl.classList.remove('has-data');
}) })
.finally(function () { setLoading(false); }); .finally(function () {
klineLoading = false;
if (!silent) setLoading(false);
});
} }
function updateQuoteMeta(data) { function updateQuoteMeta(data) {
@@ -261,7 +368,48 @@
function startQuotePoll() { function startQuotePoll() {
if (quoteTimer) clearInterval(quoteTimer); if (quoteTimer) clearInterval(quoteTimer);
loadQuote(); 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() { function bindPeriodTabs() {
@@ -273,25 +421,42 @@
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); }); tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
btn.classList.add('active'); btn.classList.add('active');
currentPeriod = btn.getAttribute('data-period') || '15m'; 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 () { document.addEventListener('DOMContentLoaded', function () {
initChart(); initChart();
bindPeriodTabs(); bindPeriodTabs();
bindZoomButtons();
var active = document.querySelector('.period-tab.active'); var active = document.querySelector('.period-tab.active');
if (active) currentPeriod = active.getAttribute('data-period') || '15m'; if (active) currentPeriod = active.getAttribute('data-period') || '15m';
var loadBtn = document.getElementById('market-load-btn'); 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 hidden = document.getElementById('market-symbol-hidden');
var input = document.getElementById('market-symbol-input'); var input = document.getElementById('market-symbol-input');
if (hidden && hidden.value) { if (hidden && hidden.value) {
if (input && !input.value) input.value = hidden.value; if (input && !input.value) input.value = hidden.value;
loadKline(); restartPollers();
loadKline(false);
} else {
updateRefreshHint();
} }
}); });
})(); })();
+27 -10
View File
@@ -388,21 +388,38 @@
.pos-del{font-size:.75rem;padding:.35rem .65rem} .pos-del{font-size:.75rem;padding:.35rem .65rem}
.trade-toolbar{display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap} .trade-toolbar{display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
.trade-switch-label{ .trade-switch-label{
display:flex;align-items:center;gap:.35rem; display:flex;align-items:center;gap:.4rem;
font-size:.68rem;color:var(--text-muted); font-size:.78rem;color:var(--text-muted);
white-space:nowrap;margin-bottom:.65rem;cursor:pointer; 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 span{line-height:1.45;color:var(--text-muted)}
.trade-switch-label input{flex-shrink:0} .trade-switch-label input{flex-shrink:0;width:auto}
.trade-table-wrap{overflow-x:auto} .trade-table-wrap{
.trade-table{font-size:.8rem} overflow:auto;
.trade-table th{font-size:.75rem;padding:.55rem .45rem} max-height:420px;
.trade-table td{padding:.45rem .4rem;vertical-align:middle} 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{ .trade-table input,.trade-table select{
padding:.35rem .45rem;font-size:.78rem;border-radius:6px;width:100%;min-width:0; padding:.35rem .45rem;font-size:.78rem;border-radius:6px;width:100%;min-width:0;
} }
.trade-table .cell-readonly{color:var(--text-primary)} .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} .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-fill{background:var(--dir-bg);color:var(--accent)}
.btn-verify{background:var(--nav-active);color:#fff} .btn-verify{background:var(--nav-active);color:#fff}
+22 -1
View File
@@ -26,11 +26,19 @@
<span class="market-quote-price" id="market-quote-price"></span> <span class="market-quote-price" id="market-quote-price"></span>
<span class="market-quote-meta text-muted" id="market-quote-meta"></span> <span class="market-quote-meta text-muted" id="market-quote-meta"></span>
</div> </div>
<div class="market-chart-toolbar">
<div class="market-chart-zoom">
<button type="button" class="chart-zoom-btn" id="chart-zoom-in" title="放大"></button>
<button type="button" class="chart-zoom-btn" id="chart-zoom-out" title="缩小"></button>
<button type="button" class="chart-zoom-btn chart-zoom-reset" id="chart-zoom-reset">重置</button>
</div>
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
</div>
<div class="market-chart-wrap"> <div class="market-chart-wrap">
<div id="market-chart" class="market-chart" aria-label="K线图"></div> <div id="market-chart" class="market-chart" aria-label="K线图"></div>
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div> <div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
</div> </div>
<p class="hint">数据来源:新浪财经。分时图为当日分钟走势;2分/2小时/周线由基础周期合成</p> <p class="hint">数据来源:新浪财经。支持滚轮/拖拽缩放 K 线;交易时段内行情与 K 线约 1 秒刷新</p>
</div> </div>
<style> <style>
@@ -61,6 +69,19 @@
} }
.market-quote-name{font-weight:600;color:var(--text-title)} .market-quote-name{font-weight:600;color:var(--text-title)}
.market-quote-price{font-size:1.35rem;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums} .market-quote-price{font-size:1.35rem;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums}
.market-chart-toolbar{
display:flex;align-items:center;justify-content:space-between;gap:.75rem;
margin-bottom:.5rem;flex-wrap:wrap;
}
.market-chart-zoom{display:flex;gap:.35rem;align-items:center}
.chart-zoom-btn{
width:32px;height:32px;padding:0;border-radius:8px;
border:1px solid var(--input-border);background:var(--toggle-bg);
color:var(--text-primary);font-size:1rem;line-height:1;cursor:pointer;
}
.chart-zoom-btn:hover{border-color:var(--accent);color:var(--accent)}
.chart-zoom-reset{width:auto;padding:0 .65rem;font-size:.75rem}
.market-refresh-hint{font-size:.72rem}
.market-chart-wrap{ .market-chart-wrap{
position:relative;border-radius:12px;border:1px solid var(--card-border); position:relative;border-radius:12px;border:1px solid var(--card-border);
background:var(--card-inner);min-height:420px; background:var(--card-inner);min-height:420px;
+2 -2
View File
@@ -1,14 +1,14 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %} {% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %}
{% block content %} {% block content %}
<div class="card" style="margin-bottom:1.25rem"> <div class="card records-trade-card" style="margin-bottom:1.25rem">
<h2>交易记录</h2> <h2>交易记录</h2>
<div class="card-body"> <div class="card-body">
<label class="trade-switch-label"> <label class="trade-switch-label">
<input type="checkbox" id="trade-edit-switch"> <input type="checkbox" id="trade-edit-switch">
<span>修改/核对开关(开启后可编辑关键字段)</span> <span>修改/核对开关(开启后可编辑关键字段)</span>
</label> </label>
<div class="trade-table-wrap card-scroll"> <div class="trade-table-wrap">
<table class="trade-table"> <table class="trade-table">
<thead> <thead>
<tr> <tr>