Use Sina-only market K-lines and editable admin login synced to .env.
Market page uses Sina for quotes and bars with an auto-follow toggle and incremental chart updates while panning. Settings lets users change username and password, persisting to the database and .env. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""Web 登录账号:settings 表 + .env 同步。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Callable
|
||||
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from env_file import update_env_vars
|
||||
|
||||
ADMIN_USERNAME_KEY = "ADMIN_USERNAME"
|
||||
ADMIN_PASSWORD_KEY = "ADMIN_PASSWORD"
|
||||
|
||||
|
||||
def save_admin_credentials(
|
||||
*,
|
||||
username: str,
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
new_password2: str,
|
||||
get_setting: Callable[[str, str], str],
|
||||
set_setting: Callable[[str, str], None],
|
||||
) -> tuple[bool, str, dict[str, str]]:
|
||||
"""
|
||||
校验原密码后更新用户名/密码,写入 settings 与 .env。
|
||||
返回 (成功, 提示, env_updates)。
|
||||
"""
|
||||
username = (username or "").strip()
|
||||
old_password = old_password or ""
|
||||
new_password = new_password or ""
|
||||
new_password2 = new_password2 or ""
|
||||
|
||||
if not username:
|
||||
return False, "用户名不能为空", {}
|
||||
if len(username) > 64:
|
||||
return False, "用户名过长(最多 64 字符)", {}
|
||||
if not re.match(r"^[A-Za-z0-9_.@-]+$", username):
|
||||
return False, "用户名仅支持字母、数字及 _ . @ -", {}
|
||||
|
||||
admin_hash = get_setting("admin_password_hash")
|
||||
if not admin_hash or not check_password_hash(admin_hash, old_password):
|
||||
return False, "原密码错误", {}
|
||||
|
||||
current_username = (get_setting("admin_username") or "").strip()
|
||||
password_change = bool(new_password or new_password2)
|
||||
|
||||
if password_change:
|
||||
if not new_password or not new_password2:
|
||||
return False, "请同时填写新密码与确认密码", {}
|
||||
if len(new_password) < 6:
|
||||
return False, "新密码至少 6 位", {}
|
||||
if new_password != new_password2:
|
||||
return False, "两次新密码不一致", {}
|
||||
|
||||
username_changed = username != current_username
|
||||
if not username_changed and not password_change:
|
||||
return False, "未修改任何内容", {}
|
||||
|
||||
set_setting("admin_username", username)
|
||||
env_updates: dict[str, str] = {ADMIN_USERNAME_KEY: username}
|
||||
|
||||
if password_change:
|
||||
set_setting("admin_password_hash", generate_password_hash(new_password))
|
||||
env_updates[ADMIN_PASSWORD_KEY] = new_password
|
||||
|
||||
try:
|
||||
update_env_vars(env_updates)
|
||||
except OSError as exc:
|
||||
return False, f"数据库已更新,但写入 .env 失败:{exc}", env_updates
|
||||
|
||||
for key, val in env_updates.items():
|
||||
os.environ[key] = val
|
||||
|
||||
parts: list[str] = []
|
||||
if username_changed:
|
||||
parts.append("用户名已更新")
|
||||
if password_change:
|
||||
parts.append("密码已更新")
|
||||
parts.append("已同步至 .env")
|
||||
return True, ";".join(parts), env_updates
|
||||
@@ -51,6 +51,7 @@ from kline_stream import kline_hub, sse_format
|
||||
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 db_conn import connect_db
|
||||
from admin_settings import save_admin_credentials
|
||||
from db_backup import (
|
||||
backup_dir,
|
||||
backup_in_progress,
|
||||
@@ -470,6 +471,8 @@ def build_market_quote_payload(
|
||||
symbol: str,
|
||||
market_code: str = "",
|
||||
sina_code: str = "",
|
||||
*,
|
||||
prefer_sina: bool = False,
|
||||
) -> dict:
|
||||
if not market_code or not sina_code:
|
||||
codes = ths_to_codes(symbol)
|
||||
@@ -479,20 +482,21 @@ def build_market_quote_payload(
|
||||
quote_source = "sina"
|
||||
price = None
|
||||
prev_close = None
|
||||
try:
|
||||
from vnpy_bridge import ctp_status, ctp_get_tick_detail
|
||||
from trading_context import get_trading_mode
|
||||
if not prefer_sina:
|
||||
try:
|
||||
from vnpy_bridge import ctp_status, ctp_get_tick_detail
|
||||
from trading_context import get_trading_mode
|
||||
|
||||
mode = get_trading_mode(get_setting)
|
||||
if ctp_status(mode).get("connected"):
|
||||
detail = ctp_get_tick_detail(mode, symbol)
|
||||
if detail.get("price"):
|
||||
price = detail["price"]
|
||||
quote_source = "ctp"
|
||||
if detail.get("pre_close") is not None:
|
||||
prev_close = detail["pre_close"]
|
||||
except Exception:
|
||||
pass
|
||||
mode = get_trading_mode(get_setting)
|
||||
if ctp_status(mode).get("connected"):
|
||||
detail = ctp_get_tick_detail(mode, symbol)
|
||||
if detail.get("price"):
|
||||
price = detail["price"]
|
||||
quote_source = "ctp"
|
||||
if detail.get("pre_close") is not None:
|
||||
prev_close = detail["pre_close"]
|
||||
except Exception:
|
||||
pass
|
||||
if price is None:
|
||||
price = fetch_price(symbol, market_code, sina_code)
|
||||
name = symbol
|
||||
@@ -715,7 +719,9 @@ def start_background_threads():
|
||||
threading.Thread(
|
||||
target=lambda: kline_hub.worker_loop(
|
||||
DB_PATH,
|
||||
build_market_quote_payload,
|
||||
lambda sym, mc, sc: build_market_quote_payload(
|
||||
sym, mc, sc, prefer_sina=True,
|
||||
),
|
||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||
),
|
||||
daemon=True,
|
||||
@@ -1553,7 +1559,7 @@ def api_kline():
|
||||
from trading_context import get_trading_mode
|
||||
|
||||
data = fetch_market_klines(
|
||||
symbol, period, DB_PATH, trading_mode=get_trading_mode(get_setting),
|
||||
symbol, period, DB_PATH, prefer_ctp=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
app.logger.warning("kline api failed: %s", exc)
|
||||
@@ -1578,19 +1584,18 @@ def api_kline_stream():
|
||||
return jsonify({"error": "请提供合约代码"}), 400
|
||||
|
||||
def generate():
|
||||
from trading_context import get_trading_mode
|
||||
|
||||
mode = get_trading_mode(get_setting)
|
||||
sub = kline_hub.subscribe(symbol, period, market_code, sina_code)
|
||||
try:
|
||||
kline_data = fetch_market_klines(
|
||||
symbol, period, DB_PATH, trading_mode=mode,
|
||||
symbol, period, DB_PATH, prefer_ctp=False,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
yield sse_format("kline", kline_data)
|
||||
yield sse_format(
|
||||
"quote",
|
||||
build_market_quote_payload(symbol, market_code, sina_code),
|
||||
build_market_quote_payload(
|
||||
symbol, market_code, sina_code, prefer_sina=True,
|
||||
),
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
@@ -1620,7 +1625,9 @@ def api_market_quote():
|
||||
sina_code = request.args.get("sina_code", "").strip()
|
||||
if not symbol and not market_code:
|
||||
return jsonify({"error": "请提供合约"}), 400
|
||||
return jsonify(build_market_quote_payload(symbol, market_code, sina_code))
|
||||
return jsonify(build_market_quote_payload(
|
||||
symbol, market_code, sina_code, prefer_sina=True,
|
||||
))
|
||||
|
||||
|
||||
@app.route("/contract")
|
||||
@@ -1834,19 +1841,17 @@ def settings():
|
||||
save_nav_items(set_setting, items)
|
||||
flash("导航显示已保存")
|
||||
elif action == "password":
|
||||
old_p = request.form.get("old_password", "")
|
||||
new_p = request.form.get("new_password", "")
|
||||
new_p2 = request.form.get("new_password2", "")
|
||||
admin_hash = get_setting("admin_password_hash")
|
||||
if not check_password_hash(admin_hash, old_p):
|
||||
flash("原密码错误")
|
||||
elif len(new_p) < 6:
|
||||
flash("新密码至少 6 位")
|
||||
elif new_p != new_p2:
|
||||
flash("两次新密码不一致")
|
||||
else:
|
||||
set_setting("admin_password_hash", generate_password_hash(new_p))
|
||||
flash("密码修改成功")
|
||||
ok, msg, _ = save_admin_credentials(
|
||||
username=request.form.get("admin_username", ""),
|
||||
old_password=request.form.get("old_password", ""),
|
||||
new_password=request.form.get("new_password", ""),
|
||||
new_password2=request.form.get("new_password2", ""),
|
||||
get_setting=get_setting,
|
||||
set_setting=set_setting,
|
||||
)
|
||||
if ok and session.get("logged_in"):
|
||||
session["username"] = (request.form.get("admin_username") or "").strip()
|
||||
flash(msg)
|
||||
return redirect(url_for("settings"))
|
||||
|
||||
webhook = get_setting("wechat_webhook")
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@
|
||||
| CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env`) |
|
||||
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
|
||||
| 企业微信 Webhook | 计划/关键位推送 |
|
||||
| 修改密码 | 管理员密码 |
|
||||
| 登录账号 | 用户名/密码,同步写入 `.env` |
|
||||
| 数据备份与恢复 | 自动/手动备份、下载压缩包、恢复说明 |
|
||||
| 深色/浅色主题 | 页头切换 |
|
||||
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
# 专有软件 — 未经授权禁止复制、传播、转售。
|
||||
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
||||
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
||||
|
||||
"""读写项目根目录 .env 文件(更新指定键,保留其余行)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
ENV_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||||
_KEY_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
||||
|
||||
|
||||
def env_file_path(path: str | None = None) -> str:
|
||||
return path or ENV_PATH
|
||||
|
||||
|
||||
def _quote_env_value(value: str) -> str:
|
||||
if value == "":
|
||||
return '""'
|
||||
if re.search(r'[\s#"\'\\=]', value):
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return value
|
||||
|
||||
|
||||
def update_env_vars(updates: dict[str, str], path: str | None = None) -> None:
|
||||
"""更新或追加 KEY=value,不改动注释与其他配置项。"""
|
||||
if not updates:
|
||||
return
|
||||
env_path = env_file_path(path)
|
||||
lines: list[str] = []
|
||||
if os.path.isfile(env_path):
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
lines = f.read().splitlines()
|
||||
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
out.append(line)
|
||||
continue
|
||||
m = _KEY_RE.match(stripped)
|
||||
if m and m.group(1) in updates:
|
||||
key = m.group(1)
|
||||
out.append(f"{key}={_quote_env_value(updates[key])}")
|
||||
seen.add(key)
|
||||
else:
|
||||
out.append(line)
|
||||
|
||||
for key, val in updates.items():
|
||||
if key not in seen:
|
||||
out.append(f"{key}={_quote_env_value(val)}")
|
||||
|
||||
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
if out:
|
||||
f.write("\n".join(out) + "\n")
|
||||
+4
-8
@@ -268,7 +268,7 @@ def fetch_market_klines(
|
||||
force_remote: bool = False,
|
||||
*,
|
||||
trading_mode: Optional[str] = None,
|
||||
prefer_ctp: bool = True,
|
||||
prefer_ctp: bool = False,
|
||||
) -> dict:
|
||||
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||
p = (period or "15m").lower()
|
||||
@@ -303,11 +303,7 @@ def fetch_market_klines(
|
||||
except Exception as exc:
|
||||
logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc)
|
||||
|
||||
need_sina = (
|
||||
force_remote
|
||||
or not ctp_bars
|
||||
or len(ctp_bars) < MIN_CTP_KLINE_BARS
|
||||
)
|
||||
need_sina = force_remote or not prefer_ctp or not ctp_bars or len(ctp_bars) < MIN_CTP_KLINE_BARS
|
||||
|
||||
if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS:
|
||||
bars = ctp_bars
|
||||
@@ -325,10 +321,10 @@ def fetch_market_klines(
|
||||
except Exception as exc:
|
||||
logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc)
|
||||
|
||||
if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS:
|
||||
if not bars or len(ctp_bars) < MIN_CTP_KLINE_BARS or not prefer_ctp:
|
||||
remote_bars = fetch_sina_klines(symbol, p)
|
||||
if remote_bars:
|
||||
if ctp_bars and ctp_connected:
|
||||
if prefer_ctp and ctp_bars and ctp_connected:
|
||||
bars = _merge_kline_bars(remote_bars, ctp_bars)
|
||||
source = "ctp+remote"
|
||||
else:
|
||||
|
||||
+1
-1
@@ -137,7 +137,7 @@ class KlineStreamHub:
|
||||
sub.period,
|
||||
db_path,
|
||||
force_remote=True,
|
||||
trading_mode=get_mode_fn() if get_mode_fn else None,
|
||||
prefer_ctp=False,
|
||||
)
|
||||
if kline_data.get("bars"):
|
||||
self.publish(sub, "kline", kline_data)
|
||||
|
||||
+102
-21
@@ -20,9 +20,11 @@
|
||||
var streamActive = false;
|
||||
var reconnectTimer = null;
|
||||
var lastData = null;
|
||||
var lastRenderedPrepared = null;
|
||||
var lastPrevClose = null;
|
||||
var chartOpts = { prevClose: false, ma: false, gapDay: false };
|
||||
var followingLatest = true;
|
||||
var autoFollow = true;
|
||||
var DEFAULT_VISIBLE_BARS = 80;
|
||||
|
||||
var PERIOD_SECONDS = {
|
||||
@@ -163,6 +165,7 @@
|
||||
ma55Series = null;
|
||||
prevCloseLine = null;
|
||||
currentChartMode = '';
|
||||
lastRenderedPrepared = null;
|
||||
}
|
||||
|
||||
function buildChart(mode) {
|
||||
@@ -313,20 +316,58 @@
|
||||
});
|
||||
}
|
||||
|
||||
function renderChart(data, preserveRange) {
|
||||
if (!chartEl || !window.LightweightCharts) return;
|
||||
lastData = data;
|
||||
if (data.prev_close != null) lastPrevClose = data.prev_close;
|
||||
function shouldPreserveView() {
|
||||
return !autoFollow || !followingLatest;
|
||||
}
|
||||
|
||||
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
||||
var mode = isLine ? 'line' : 'candle';
|
||||
if (!chart || currentChartMode !== mode) buildChart(mode);
|
||||
if (!chart) return;
|
||||
function applyBarUpdate(bar, mode, prepared) {
|
||||
if (mode === 'line') {
|
||||
areaSeries.update({ time: bar.time, value: bar.close });
|
||||
return;
|
||||
}
|
||||
candleSeries.update({
|
||||
time: bar.time,
|
||||
open: bar.open,
|
||||
high: bar.high,
|
||||
low: bar.low,
|
||||
close: bar.close,
|
||||
});
|
||||
var up = bar.close >= bar.open;
|
||||
var c = themeColors();
|
||||
volumeSeries.update({
|
||||
time: bar.time,
|
||||
value: bar.volume,
|
||||
color: up ? c.up : c.down,
|
||||
});
|
||||
if (chartOpts.ma && ma21Series && ma55Series && prepared && prepared.length) {
|
||||
var ma21 = calcMA(21, prepared);
|
||||
var ma55 = calcMA(55, prepared);
|
||||
if (ma21.length) ma21Series.update(ma21[ma21.length - 1]);
|
||||
if (ma55.length) ma55Series.update(ma55[ma55.length - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
var prepared = prepareBars(data.bars || [], data.period || currentPeriod);
|
||||
data.preparedBars = prepared;
|
||||
if (!prepared.length) return;
|
||||
function tryIncrementalUpdate(prepared, mode) {
|
||||
if (!lastRenderedPrepared || !prepared.length) return false;
|
||||
var prev = lastRenderedPrepared;
|
||||
var prevLast = prev[prev.length - 1];
|
||||
var newLast = prepared[prepared.length - 1];
|
||||
|
||||
if (prepared.length === prev.length && newLast.time === prevLast.time) {
|
||||
applyBarUpdate(newLast, mode, prepared);
|
||||
return true;
|
||||
}
|
||||
if (prepared.length === prev.length + 1 && prepared[prepared.length - 2].time === prevLast.time) {
|
||||
applyBarUpdate(newLast, mode, prepared);
|
||||
if (autoFollow && followingLatest) {
|
||||
setVisibleRange(prepared, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderChartFull(prepared, data, mode, preserveRange) {
|
||||
if (mode === 'line') {
|
||||
areaSeries.setData(prepared.map(function (b) {
|
||||
return { time: b.time, value: b.close };
|
||||
@@ -350,8 +391,33 @@
|
||||
}
|
||||
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close);
|
||||
}
|
||||
if (!shouldPreserveView()) {
|
||||
setVisibleRange(prepared, !!preserveRange);
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleRange(prepared, !!preserveRange);
|
||||
function renderChart(data, options) {
|
||||
options = options || {};
|
||||
if (!chartEl || !window.LightweightCharts) return;
|
||||
lastData = data;
|
||||
if (data.prev_close != null) lastPrevClose = data.prev_close;
|
||||
|
||||
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
||||
var mode = isLine ? 'line' : 'candle';
|
||||
if (!chart || currentChartMode !== mode) buildChart(mode);
|
||||
if (!chart) return;
|
||||
|
||||
var prepared = prepareBars(data.bars || [], data.period || currentPeriod);
|
||||
data.preparedBars = prepared;
|
||||
if (!prepared.length) return;
|
||||
|
||||
if (!options.forceFull && shouldPreserveView() && tryIncrementalUpdate(prepared, mode)) {
|
||||
lastRenderedPrepared = prepared;
|
||||
return;
|
||||
}
|
||||
|
||||
renderChartFull(prepared, data, mode, options.preserveRange);
|
||||
lastRenderedPrepared = prepared;
|
||||
}
|
||||
|
||||
function periodLabel(key) {
|
||||
@@ -388,8 +454,6 @@
|
||||
}
|
||||
|
||||
function klineSourceLabel(src) {
|
||||
if (src === 'ctp') return 'CTP';
|
||||
if (src === 'ctp+remote') return '新浪+CTP';
|
||||
if (src === 'local') return '本地缓存';
|
||||
return '新浪';
|
||||
}
|
||||
@@ -414,9 +478,9 @@
|
||||
src = ' · ' + klineSourceLabel(lastData.source);
|
||||
}
|
||||
if (isTradingSession()) {
|
||||
el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src;
|
||||
el.textContent = '新浪数据 · 交易中 SSE 推送' + src;
|
||||
} else {
|
||||
el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src;
|
||||
el.textContent = '新浪数据 · 非交易时段低频刷新' + src;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +559,7 @@
|
||||
var data = JSON.parse(e.data);
|
||||
if (!data.bars || !data.bars.length) return;
|
||||
hideEmptyOverlay();
|
||||
renderChart(data, lastData !== null);
|
||||
renderChart(data, { preserveRange: lastData !== null });
|
||||
updateQuoteMeta(data);
|
||||
if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close);
|
||||
updateRefreshHint(false);
|
||||
@@ -522,7 +586,7 @@
|
||||
if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period));
|
||||
if (data.source) parts.push('K线 ' + klineSourceLabel(data.source));
|
||||
if (data.quote_source) {
|
||||
parts.push('报价 ' + (data.quote_source === 'ctp' ? 'CTP' : '新浪'));
|
||||
parts.push('报价 新浪');
|
||||
}
|
||||
meta.textContent = parts.join(' · ');
|
||||
}
|
||||
@@ -580,6 +644,21 @@
|
||||
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
|
||||
}
|
||||
|
||||
function bindAutoButton() {
|
||||
var btn = document.getElementById('market-auto-btn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function () {
|
||||
autoFollow = !autoFollow;
|
||||
btn.classList.toggle('is-active', autoFollow);
|
||||
if (autoFollow) {
|
||||
followingLatest = true;
|
||||
if (lastData && lastData.preparedBars) {
|
||||
setVisibleRange(lastData.preparedBars, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindChartOptions() {
|
||||
var prevCb = document.getElementById('chart-opt-prev-close');
|
||||
var maCb = document.getElementById('chart-opt-ma');
|
||||
@@ -597,7 +676,7 @@
|
||||
chartOpts.ma = maCb.checked;
|
||||
if (lastData) {
|
||||
destroyChart();
|
||||
renderChart(lastData, false);
|
||||
renderChart(lastData, { forceFull: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -605,7 +684,7 @@
|
||||
gapCb.addEventListener('change', function () {
|
||||
chartOpts.gapDay = gapCb.checked;
|
||||
followingLatest = true;
|
||||
if (lastData) renderChart(lastData, false);
|
||||
if (lastData) renderChart(lastData, { forceFull: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -617,13 +696,14 @@
|
||||
}
|
||||
bindPeriodTabs();
|
||||
bindZoomButtons();
|
||||
bindAutoButton();
|
||||
bindChartOptions();
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
if (e.target.closest('[data-theme-pick]') && lastData) {
|
||||
setTimeout(function () {
|
||||
destroyChart();
|
||||
renderChart(lastData, false);
|
||||
renderChart(lastData, { forceFull: true });
|
||||
}, 80);
|
||||
}
|
||||
});
|
||||
@@ -640,6 +720,7 @@
|
||||
input.addEventListener('symbol-selected', function () {
|
||||
lastPrevClose = null;
|
||||
lastData = null;
|
||||
lastRenderedPrepared = null;
|
||||
destroyChart();
|
||||
updatePrevCloseDisplay(null);
|
||||
loadKline(true);
|
||||
|
||||
+20
-1
@@ -41,12 +41,16 @@
|
||||
</div>
|
||||
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
||||
</div>
|
||||
<div class="market-chart-auto-row">
|
||||
<button type="button" class="chart-auto-btn is-active" id="market-auto-btn" title="开启后自动跟随最新 K 线">自动</button>
|
||||
<span class="hint market-auto-hint">关闭后可自由拖动查看历史,刷新时只更新最新 K 线,不重置视图</span>
|
||||
</div>
|
||||
<div class="market-chart-wrap" id="market-chart-wrap">
|
||||
<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-loading" id="market-chart-loading">连接中…</div>
|
||||
</div>
|
||||
<p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。</p>
|
||||
<p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。K 线与报价均使用<strong>新浪</strong>数据。滚轮缩放、拖拽平移;关闭「自动」后拖动查看历史时,推送更新不会重置画面。</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -102,6 +106,21 @@
|
||||
.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-auto-row{
|
||||
display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;
|
||||
margin-bottom:.5rem;
|
||||
}
|
||||
.chart-auto-btn{
|
||||
padding:.38rem .85rem;border-radius:999px;
|
||||
border:1px solid var(--input-border);background:var(--toggle-bg);
|
||||
color:var(--text-muted);font-size:.78rem;cursor:pointer;width:auto;
|
||||
}
|
||||
.chart-auto-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.chart-auto-btn.is-active{
|
||||
background:linear-gradient(135deg,var(--accent),var(--accent-2));
|
||||
border-color:transparent;color:#fff;
|
||||
}
|
||||
.market-auto-hint{font-size:.72rem;margin:0}
|
||||
.market-chart-wrap{
|
||||
position:relative;border-radius:12px;border:1px solid var(--card-border);
|
||||
background:var(--card-inner);
|
||||
|
||||
+11
-9
@@ -420,28 +420,30 @@
|
||||
</details>
|
||||
{% endcall %}
|
||||
|
||||
{% call settings_card('password', '修改密码', 'settings-compact-card') %}
|
||||
{% call settings_card('password', '登录账号', 'settings-compact-card') %}
|
||||
<form action="{{ url_for('settings') }}" method="post" class="settings-password-form">
|
||||
<input type="hidden" name="action" value="password">
|
||||
<div class="field field-full">
|
||||
<label>当前账号</label>
|
||||
<input type="text" value="{{ username }}" disabled>
|
||||
<label>用户名</label>
|
||||
<input name="admin_username" type="text" value="{{ username }}" required maxlength="64"
|
||||
pattern="[A-Za-z0-9_.@-]+" autocomplete="username">
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="field field-full">
|
||||
<label>原密码</label>
|
||||
<input name="old_password" type="password" required>
|
||||
<input name="old_password" type="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>新密码</label>
|
||||
<input name="new_password" type="password" required minlength="6" placeholder="至少 6 位">
|
||||
<input name="new_password" type="password" minlength="6" placeholder="留空则不修改" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field field-full">
|
||||
<div class="field">
|
||||
<label>确认新密码</label>
|
||||
<input name="new_password2" type="password" required minlength="6">
|
||||
<input name="new_password2" type="password" minlength="6" placeholder="修改密码时填写" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field-full">
|
||||
<button type="submit" class="btn-primary">修改密码</button>
|
||||
<button type="submit" class="btn-primary">保存账号</button>
|
||||
</div>
|
||||
<p class="hint" style="margin:.45rem 0 0;font-size:.72rem">保存后写入数据库,并同步至 <code>.env</code> 的 <code>ADMIN_USERNAME</code> / <code>ADMIN_PASSWORD</code>。</p>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user