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 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
|
||||||
from db_conn import connect_db
|
from db_conn import connect_db
|
||||||
|
from admin_settings import save_admin_credentials
|
||||||
from db_backup import (
|
from db_backup import (
|
||||||
backup_dir,
|
backup_dir,
|
||||||
backup_in_progress,
|
backup_in_progress,
|
||||||
@@ -470,6 +471,8 @@ def build_market_quote_payload(
|
|||||||
symbol: str,
|
symbol: str,
|
||||||
market_code: str = "",
|
market_code: str = "",
|
||||||
sina_code: str = "",
|
sina_code: str = "",
|
||||||
|
*,
|
||||||
|
prefer_sina: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not market_code or not sina_code:
|
if not market_code or not sina_code:
|
||||||
codes = ths_to_codes(symbol)
|
codes = ths_to_codes(symbol)
|
||||||
@@ -479,6 +482,7 @@ def build_market_quote_payload(
|
|||||||
quote_source = "sina"
|
quote_source = "sina"
|
||||||
price = None
|
price = None
|
||||||
prev_close = None
|
prev_close = None
|
||||||
|
if not prefer_sina:
|
||||||
try:
|
try:
|
||||||
from vnpy_bridge import ctp_status, ctp_get_tick_detail
|
from vnpy_bridge import ctp_status, ctp_get_tick_detail
|
||||||
from trading_context import get_trading_mode
|
from trading_context import get_trading_mode
|
||||||
@@ -715,7 +719,9 @@ def start_background_threads():
|
|||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=lambda: kline_hub.worker_loop(
|
target=lambda: kline_hub.worker_loop(
|
||||||
DB_PATH,
|
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),
|
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||||
),
|
),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
@@ -1553,7 +1559,7 @@ def api_kline():
|
|||||||
from trading_context import get_trading_mode
|
from trading_context import get_trading_mode
|
||||||
|
|
||||||
data = fetch_market_klines(
|
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:
|
except Exception as exc:
|
||||||
app.logger.warning("kline api failed: %s", exc)
|
app.logger.warning("kline api failed: %s", exc)
|
||||||
@@ -1578,19 +1584,18 @@ def api_kline_stream():
|
|||||||
return jsonify({"error": "请提供合约代码"}), 400
|
return jsonify({"error": "请提供合约代码"}), 400
|
||||||
|
|
||||||
def generate():
|
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)
|
sub = kline_hub.subscribe(symbol, period, market_code, sina_code)
|
||||||
try:
|
try:
|
||||||
kline_data = fetch_market_klines(
|
kline_data = fetch_market_klines(
|
||||||
symbol, period, DB_PATH, trading_mode=mode,
|
symbol, period, DB_PATH, prefer_ctp=False,
|
||||||
)
|
)
|
||||||
if kline_data.get("bars"):
|
if kline_data.get("bars"):
|
||||||
yield sse_format("kline", kline_data)
|
yield sse_format("kline", kline_data)
|
||||||
yield sse_format(
|
yield sse_format(
|
||||||
"quote",
|
"quote",
|
||||||
build_market_quote_payload(symbol, market_code, sina_code),
|
build_market_quote_payload(
|
||||||
|
symbol, market_code, sina_code, prefer_sina=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -1620,7 +1625,9 @@ def api_market_quote():
|
|||||||
sina_code = request.args.get("sina_code", "").strip()
|
sina_code = request.args.get("sina_code", "").strip()
|
||||||
if not symbol and not market_code:
|
if not symbol and not market_code:
|
||||||
return jsonify({"error": "请提供合约"}), 400
|
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")
|
@app.route("/contract")
|
||||||
@@ -1834,19 +1841,17 @@ def settings():
|
|||||||
save_nav_items(set_setting, items)
|
save_nav_items(set_setting, items)
|
||||||
flash("导航显示已保存")
|
flash("导航显示已保存")
|
||||||
elif action == "password":
|
elif action == "password":
|
||||||
old_p = request.form.get("old_password", "")
|
ok, msg, _ = save_admin_credentials(
|
||||||
new_p = request.form.get("new_password", "")
|
username=request.form.get("admin_username", ""),
|
||||||
new_p2 = request.form.get("new_password2", "")
|
old_password=request.form.get("old_password", ""),
|
||||||
admin_hash = get_setting("admin_password_hash")
|
new_password=request.form.get("new_password", ""),
|
||||||
if not check_password_hash(admin_hash, old_p):
|
new_password2=request.form.get("new_password2", ""),
|
||||||
flash("原密码错误")
|
get_setting=get_setting,
|
||||||
elif len(new_p) < 6:
|
set_setting=set_setting,
|
||||||
flash("新密码至少 6 位")
|
)
|
||||||
elif new_p != new_p2:
|
if ok and session.get("logged_in"):
|
||||||
flash("两次新密码不一致")
|
session["username"] = (request.form.get("admin_username") or "").strip()
|
||||||
else:
|
flash(msg)
|
||||||
set_setting("admin_password_hash", generate_password_hash(new_p))
|
|
||||||
flash("密码修改成功")
|
|
||||||
return redirect(url_for("settings"))
|
return redirect(url_for("settings"))
|
||||||
|
|
||||||
webhook = get_setting("wechat_webhook")
|
webhook = get_setting("wechat_webhook")
|
||||||
|
|||||||
+1
-1
@@ -176,7 +176,7 @@
|
|||||||
| CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env`) |
|
| CTP 连接 | SimNow / 实盘前置与账号(可覆盖 `.env`) |
|
||||||
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
|
| 参考资金 | CTP 未连接时用于可开仓筛选与估算 |
|
||||||
| 企业微信 Webhook | 计划/关键位推送 |
|
| 企业微信 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,
|
force_remote: bool = False,
|
||||||
*,
|
*,
|
||||||
trading_mode: Optional[str] = None,
|
trading_mode: Optional[str] = None,
|
||||||
prefer_ctp: bool = True,
|
prefer_ctp: bool = False,
|
||||||
) -> dict:
|
) -> 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()
|
||||||
@@ -303,11 +303,7 @@ def fetch_market_klines(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc)
|
logger.debug("ctp kline fetch failed %s %s: %s", symbol, p, exc)
|
||||||
|
|
||||||
need_sina = (
|
need_sina = force_remote or not prefer_ctp or not ctp_bars or len(ctp_bars) < MIN_CTP_KLINE_BARS
|
||||||
force_remote
|
|
||||||
or not ctp_bars
|
|
||||||
or len(ctp_bars) < MIN_CTP_KLINE_BARS
|
|
||||||
)
|
|
||||||
|
|
||||||
if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS:
|
if ctp_bars and len(ctp_bars) >= MIN_CTP_KLINE_BARS:
|
||||||
bars = ctp_bars
|
bars = ctp_bars
|
||||||
@@ -325,10 +321,10 @@ def fetch_market_klines(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("kline cache read failed %s %s: %s", chart_sym, p, 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)
|
remote_bars = fetch_sina_klines(symbol, p)
|
||||||
if remote_bars:
|
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)
|
bars = _merge_kline_bars(remote_bars, ctp_bars)
|
||||||
source = "ctp+remote"
|
source = "ctp+remote"
|
||||||
else:
|
else:
|
||||||
|
|||||||
+1
-1
@@ -137,7 +137,7 @@ class KlineStreamHub:
|
|||||||
sub.period,
|
sub.period,
|
||||||
db_path,
|
db_path,
|
||||||
force_remote=True,
|
force_remote=True,
|
||||||
trading_mode=get_mode_fn() if get_mode_fn else None,
|
prefer_ctp=False,
|
||||||
)
|
)
|
||||||
if kline_data.get("bars"):
|
if kline_data.get("bars"):
|
||||||
self.publish(sub, "kline", kline_data)
|
self.publish(sub, "kline", kline_data)
|
||||||
|
|||||||
+102
-21
@@ -20,9 +20,11 @@
|
|||||||
var streamActive = false;
|
var streamActive = false;
|
||||||
var reconnectTimer = null;
|
var reconnectTimer = null;
|
||||||
var lastData = null;
|
var lastData = null;
|
||||||
|
var lastRenderedPrepared = null;
|
||||||
var lastPrevClose = null;
|
var lastPrevClose = null;
|
||||||
var chartOpts = { prevClose: false, ma: false, gapDay: false };
|
var chartOpts = { prevClose: false, ma: false, gapDay: false };
|
||||||
var followingLatest = true;
|
var followingLatest = true;
|
||||||
|
var autoFollow = true;
|
||||||
var DEFAULT_VISIBLE_BARS = 80;
|
var DEFAULT_VISIBLE_BARS = 80;
|
||||||
|
|
||||||
var PERIOD_SECONDS = {
|
var PERIOD_SECONDS = {
|
||||||
@@ -163,6 +165,7 @@
|
|||||||
ma55Series = null;
|
ma55Series = null;
|
||||||
prevCloseLine = null;
|
prevCloseLine = null;
|
||||||
currentChartMode = '';
|
currentChartMode = '';
|
||||||
|
lastRenderedPrepared = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChart(mode) {
|
function buildChart(mode) {
|
||||||
@@ -313,20 +316,58 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChart(data, preserveRange) {
|
function shouldPreserveView() {
|
||||||
if (!chartEl || !window.LightweightCharts) return;
|
return !autoFollow || !followingLatest;
|
||||||
lastData = data;
|
}
|
||||||
if (data.prev_close != null) lastPrevClose = data.prev_close;
|
|
||||||
|
|
||||||
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
function applyBarUpdate(bar, mode, prepared) {
|
||||||
var mode = isLine ? 'line' : 'candle';
|
if (mode === 'line') {
|
||||||
if (!chart || currentChartMode !== mode) buildChart(mode);
|
areaSeries.update({ time: bar.time, value: bar.close });
|
||||||
if (!chart) return;
|
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);
|
function tryIncrementalUpdate(prepared, mode) {
|
||||||
data.preparedBars = prepared;
|
if (!lastRenderedPrepared || !prepared.length) return false;
|
||||||
if (!prepared.length) return;
|
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') {
|
if (mode === 'line') {
|
||||||
areaSeries.setData(prepared.map(function (b) {
|
areaSeries.setData(prepared.map(function (b) {
|
||||||
return { time: b.time, value: b.close };
|
return { time: b.time, value: b.close };
|
||||||
@@ -350,9 +391,34 @@
|
|||||||
}
|
}
|
||||||
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close);
|
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) {
|
function periodLabel(key) {
|
||||||
var tabs = document.querySelectorAll('.period-tab');
|
var tabs = document.querySelectorAll('.period-tab');
|
||||||
@@ -388,8 +454,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function klineSourceLabel(src) {
|
function klineSourceLabel(src) {
|
||||||
if (src === 'ctp') return 'CTP';
|
|
||||||
if (src === 'ctp+remote') return '新浪+CTP';
|
|
||||||
if (src === 'local') return '本地缓存';
|
if (src === 'local') return '本地缓存';
|
||||||
return '新浪';
|
return '新浪';
|
||||||
}
|
}
|
||||||
@@ -414,9 +478,9 @@
|
|||||||
src = ' · ' + klineSourceLabel(lastData.source);
|
src = ' · ' + klineSourceLabel(lastData.source);
|
||||||
}
|
}
|
||||||
if (isTradingSession()) {
|
if (isTradingSession()) {
|
||||||
el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src;
|
el.textContent = '新浪数据 · 交易中 SSE 推送' + src;
|
||||||
} else {
|
} else {
|
||||||
el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src;
|
el.textContent = '新浪数据 · 非交易时段低频刷新' + src;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +559,7 @@
|
|||||||
var data = JSON.parse(e.data);
|
var data = JSON.parse(e.data);
|
||||||
if (!data.bars || !data.bars.length) return;
|
if (!data.bars || !data.bars.length) return;
|
||||||
hideEmptyOverlay();
|
hideEmptyOverlay();
|
||||||
renderChart(data, lastData !== null);
|
renderChart(data, { preserveRange: lastData !== null });
|
||||||
updateQuoteMeta(data);
|
updateQuoteMeta(data);
|
||||||
if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close);
|
if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close);
|
||||||
updateRefreshHint(false);
|
updateRefreshHint(false);
|
||||||
@@ -522,7 +586,7 @@
|
|||||||
if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period));
|
if (data.count) parts.push('共 ' + data.count + ' 根 · ' + periodLabel(data.period));
|
||||||
if (data.source) parts.push('K线 ' + klineSourceLabel(data.source));
|
if (data.source) parts.push('K线 ' + klineSourceLabel(data.source));
|
||||||
if (data.quote_source) {
|
if (data.quote_source) {
|
||||||
parts.push('报价 ' + (data.quote_source === 'ctp' ? 'CTP' : '新浪'));
|
parts.push('报价 新浪');
|
||||||
}
|
}
|
||||||
meta.textContent = parts.join(' · ');
|
meta.textContent = parts.join(' · ');
|
||||||
}
|
}
|
||||||
@@ -580,6 +644,21 @@
|
|||||||
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
|
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() {
|
function bindChartOptions() {
|
||||||
var prevCb = document.getElementById('chart-opt-prev-close');
|
var prevCb = document.getElementById('chart-opt-prev-close');
|
||||||
var maCb = document.getElementById('chart-opt-ma');
|
var maCb = document.getElementById('chart-opt-ma');
|
||||||
@@ -597,7 +676,7 @@
|
|||||||
chartOpts.ma = maCb.checked;
|
chartOpts.ma = maCb.checked;
|
||||||
if (lastData) {
|
if (lastData) {
|
||||||
destroyChart();
|
destroyChart();
|
||||||
renderChart(lastData, false);
|
renderChart(lastData, { forceFull: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -605,7 +684,7 @@
|
|||||||
gapCb.addEventListener('change', function () {
|
gapCb.addEventListener('change', function () {
|
||||||
chartOpts.gapDay = gapCb.checked;
|
chartOpts.gapDay = gapCb.checked;
|
||||||
followingLatest = true;
|
followingLatest = true;
|
||||||
if (lastData) renderChart(lastData, false);
|
if (lastData) renderChart(lastData, { forceFull: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,13 +696,14 @@
|
|||||||
}
|
}
|
||||||
bindPeriodTabs();
|
bindPeriodTabs();
|
||||||
bindZoomButtons();
|
bindZoomButtons();
|
||||||
|
bindAutoButton();
|
||||||
bindChartOptions();
|
bindChartOptions();
|
||||||
|
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (e.target.closest('[data-theme-pick]') && lastData) {
|
if (e.target.closest('[data-theme-pick]') && lastData) {
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
destroyChart();
|
destroyChart();
|
||||||
renderChart(lastData, false);
|
renderChart(lastData, { forceFull: true });
|
||||||
}, 80);
|
}, 80);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -640,6 +720,7 @@
|
|||||||
input.addEventListener('symbol-selected', function () {
|
input.addEventListener('symbol-selected', function () {
|
||||||
lastPrevClose = null;
|
lastPrevClose = null;
|
||||||
lastData = null;
|
lastData = null;
|
||||||
|
lastRenderedPrepared = null;
|
||||||
destroyChart();
|
destroyChart();
|
||||||
updatePrevCloseDisplay(null);
|
updatePrevCloseDisplay(null);
|
||||||
loadKline(true);
|
loadKline(true);
|
||||||
|
|||||||
+20
-1
@@ -41,12 +41,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
||||||
</div>
|
</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 class="market-chart-wrap" id="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 class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -102,6 +106,21 @@
|
|||||||
.chart-zoom-btn:hover{border-color:var(--accent);color:var(--accent)}
|
.chart-zoom-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
.chart-zoom-reset{width:auto;padding:0 .65rem;font-size:.75rem}
|
.chart-zoom-reset{width:auto;padding:0 .65rem;font-size:.75rem}
|
||||||
.market-refresh-hint{font-size:.72rem}
|
.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{
|
.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);
|
background:var(--card-inner);
|
||||||
|
|||||||
+11
-9
@@ -420,28 +420,30 @@
|
|||||||
</details>
|
</details>
|
||||||
{% endcall %}
|
{% 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">
|
<form action="{{ url_for('settings') }}" method="post" class="settings-password-form">
|
||||||
<input type="hidden" name="action" value="password">
|
<input type="hidden" name="action" value="password">
|
||||||
<div class="field field-full">
|
<div class="field field-full">
|
||||||
<label>当前账号</label>
|
<label>用户名</label>
|
||||||
<input type="text" value="{{ username }}" disabled>
|
<input name="admin_username" type="text" value="{{ username }}" required maxlength="64"
|
||||||
|
pattern="[A-Za-z0-9_.@-]+" autocomplete="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field field-full">
|
||||||
<label>原密码</label>
|
<label>原密码</label>
|
||||||
<input name="old_password" type="password" required>
|
<input name="old_password" type="password" required autocomplete="current-password">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>新密码</label>
|
<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>
|
||||||
<div class="field field-full">
|
<div class="field">
|
||||||
<label>确认新密码</label>
|
<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>
|
||||||
<div class="field-full">
|
<div class="field-full">
|
||||||
<button type="submit" class="btn-primary">修改密码</button>
|
<button type="submit" class="btn-primary">保存账号</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user