diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py
index e6bd72e..7fb3f6e 100644
--- a/crypto_monitor_binance/app.py
+++ b/crypto_monitor_binance/app.py
@@ -9352,29 +9352,28 @@ def del_order(id):
@app.route("/add_journal", methods=["POST"])
@login_required
def add_journal():
+ from lib.instance.journal_chart_async_lib import journal_ajax_or_flash_error
+
d = request.form
order_type_norm = normalize_journal_order_type(d.get("order_type"))
if not order_type_norm:
- flash("请选择下单类型")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择下单类型", redirect_fn=_redirect_records)
direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint"))
if not direction_norm:
- flash("请选择方向")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择方向", redirect_fn=_redirect_records)
entry_reason_norm = normalize_journal_entry_reason(
d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False
)
if not entry_reason_norm:
- flash("请选择开仓类型")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择开仓类型", redirect_fn=_redirect_records)
early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger"))
early_exit_note = str(d.get("early_exit_note") or "").strip()
if not early_exit_trigger:
- flash("请选择离场触发")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择离场触发", redirect_fn=_redirect_records)
if early_exit_trigger == "手动平仓" and not early_exit_note:
- flash("手工平仓必须填写补充说明")
- return _redirect_records()
+ return journal_ajax_or_flash_error(
+ request, "手工平仓必须填写补充说明", redirect_fn=_redirect_records
+ )
if early_exit_trigger != "手动平仓":
early_exit_note = ""
# 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」
@@ -9407,14 +9406,14 @@ def add_journal():
want_exchange_chart = (
not has_manual_uploads
+ and ORDER_CHART_ENABLED
and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes")
)
- chart_msg = None
- if want_exchange_chart and ORDER_CHART_ENABLED:
+ chart_job = None
+ if want_exchange_chart:
coin = (d.get("coin") or "").strip().upper()
symbol_guess = normalize_symbol_input(coin) or coin
exchange_symbol = normalize_exchange_symbol(symbol_guess)
- title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
journal_tfs = parse_journal_chart_timeframes(
d.get("journal_chart_tf1"),
d.get("journal_chart_tf2"),
@@ -9422,36 +9421,21 @@ def add_journal():
)
journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor"))
- marker_payload = {
- "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
- "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
- "entry_price": d.get("entry_price_hint"),
- "exit_price": d.get("exit_price_hint"),
- "stop_loss_price": d.get("stop_loss_hint"),
- "chart_anchor": chart_anchor,
- "now_ts_ms": int(app_now().timestamp() * 1000),
+ chart_job = {
+ "exchange_symbol": exchange_symbol,
+ "title_prefix": f"{symbol_guess} journal {entry_id[:8]}",
+ "journal_tfs": journal_tfs,
+ "journal_limit": journal_limit,
+ "marker_payload": {
+ "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
+ "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
+ "entry_price": d.get("entry_price_hint"),
+ "exit_price": d.get("exit_price_hint"),
+ "stop_loss_price": d.get("stop_loss_hint"),
+ "chart_anchor": chart_anchor,
+ "now_ts_ms": int(app_now().timestamp() * 1000),
+ },
}
- try:
- chart_fname = f"journal_{entry_id}.png"
- saved = generate_multi_timeframe_chart_png(
- exchange_symbol,
- title_prefix,
- timeframes=journal_tfs,
- limit=journal_limit,
- out_dir=app.config["UPLOAD_FOLDER"],
- filename=chart_fname,
- filename_prefix="journal",
- marker_payload=marker_payload,
- marker_timeframes={x.strip().lower() for x in journal_tfs},
- layout="vertical",
- )
- if saved:
- image_filename = saved
- chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}"
- else:
- chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,Binance 网络/代理是否正常."
- except Exception as e:
- chart_msg = f"自动生成K线图失败:{str(e)}"
conn = get_db()
conn.execute(
@@ -9488,10 +9472,38 @@ def add_journal():
)
conn.commit()
conn.close()
- if chart_msg:
- flash(f"交易复盘记录已保存.{chart_msg}")
+ if chart_job:
+ from lib.instance.journal_chart_async_lib import schedule_journal_exchange_chart
+
+ schedule_journal_exchange_chart(
+ entry_id=entry_id,
+ exchange_symbol=chart_job["exchange_symbol"],
+ title_prefix=chart_job["title_prefix"],
+ journal_tfs=chart_job["journal_tfs"],
+ journal_limit=chart_job["journal_limit"],
+ marker_payload=chart_job["marker_payload"],
+ upload_folder=app.config["UPLOAD_FOLDER"],
+ generate_chart_fn=generate_multi_timeframe_chart_png,
+ get_db_fn=get_db,
+ )
+ msg = (
+ "交易复盘记录已保存.K 线图后台生成中"
+ f"({'/'.join(chart_job['journal_tfs'])} 各{chart_job['journal_limit']}根),稍后刷新列表可见."
+ )
else:
- flash("交易复盘记录已保存")
+ msg = "交易复盘记录已保存"
+ from lib.instance.journal_chart_async_lib import request_wants_journal_ajax
+
+ if request_wants_journal_ajax(request):
+ return jsonify(
+ {
+ "ok": True,
+ "id": entry_id,
+ "msg": msg,
+ "chart_pending": bool(chart_job),
+ }
+ )
+ flash(msg)
return _redirect_records()
diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py
index e8c8ca0..07b89af 100644
--- a/crypto_monitor_gate/app.py
+++ b/crypto_monitor_gate/app.py
@@ -9190,29 +9190,28 @@ def del_order(id):
@app.route("/add_journal", methods=["POST"])
@login_required
def add_journal():
+ from lib.instance.journal_chart_async_lib import journal_ajax_or_flash_error
+
d = request.form
order_type_norm = normalize_journal_order_type(d.get("order_type"))
if not order_type_norm:
- flash("请选择下单类型")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择下单类型", redirect_fn=_redirect_records)
direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint"))
if not direction_norm:
- flash("请选择方向")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择方向", redirect_fn=_redirect_records)
entry_reason_norm = normalize_journal_entry_reason(
d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False
)
if not entry_reason_norm:
- flash("请选择开仓类型")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择开仓类型", redirect_fn=_redirect_records)
early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger"))
early_exit_note = str(d.get("early_exit_note") or "").strip()
if not early_exit_trigger:
- flash("请选择离场触发")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择离场触发", redirect_fn=_redirect_records)
if early_exit_trigger == "手动平仓" and not early_exit_note:
- flash("手工平仓必须填写补充说明")
- return _redirect_records()
+ return journal_ajax_or_flash_error(
+ request, "手工平仓必须填写补充说明", redirect_fn=_redirect_records
+ )
if early_exit_trigger != "手动平仓":
early_exit_note = ""
# 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」
@@ -9245,14 +9244,14 @@ def add_journal():
want_exchange_chart = (
not has_manual_uploads
+ and ORDER_CHART_ENABLED
and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes")
)
- chart_msg = None
- if want_exchange_chart and ORDER_CHART_ENABLED:
+ chart_job = None
+ if want_exchange_chart:
coin = (d.get("coin") or "").strip().upper()
symbol_guess = normalize_symbol_input(coin) or coin
exchange_symbol = normalize_exchange_symbol(symbol_guess)
- title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
journal_tfs = parse_journal_chart_timeframes(
d.get("journal_chart_tf1"),
d.get("journal_chart_tf2"),
@@ -9260,36 +9259,21 @@ def add_journal():
)
journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor"))
- marker_payload = {
- "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
- "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
- "entry_price": d.get("entry_price_hint"),
- "exit_price": d.get("exit_price_hint"),
- "stop_loss_price": d.get("stop_loss_hint"),
- "chart_anchor": chart_anchor,
- "now_ts_ms": int(app_now().timestamp() * 1000),
+ chart_job = {
+ "exchange_symbol": exchange_symbol,
+ "title_prefix": f"{symbol_guess} journal {entry_id[:8]}",
+ "journal_tfs": journal_tfs,
+ "journal_limit": journal_limit,
+ "marker_payload": {
+ "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
+ "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
+ "entry_price": d.get("entry_price_hint"),
+ "exit_price": d.get("exit_price_hint"),
+ "stop_loss_price": d.get("stop_loss_hint"),
+ "chart_anchor": chart_anchor,
+ "now_ts_ms": int(app_now().timestamp() * 1000),
+ },
}
- try:
- chart_fname = f"journal_{entry_id}.png"
- saved = generate_multi_timeframe_chart_png(
- exchange_symbol,
- title_prefix,
- timeframes=journal_tfs,
- limit=journal_limit,
- out_dir=app.config["UPLOAD_FOLDER"],
- filename=chart_fname,
- filename_prefix="journal",
- marker_payload=marker_payload,
- marker_timeframes={x.strip().lower() for x in journal_tfs},
- layout="vertical",
- )
- if saved:
- image_filename = saved
- chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}"
- else:
- chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,Gate 网络/代理是否正常."
- except Exception as e:
- chart_msg = f"自动生成K线图失败:{str(e)}"
conn = get_db()
conn.execute(
@@ -9326,10 +9310,38 @@ def add_journal():
)
conn.commit()
conn.close()
- if chart_msg:
- flash(f"交易复盘记录已保存.{chart_msg}")
+ if chart_job:
+ from lib.instance.journal_chart_async_lib import schedule_journal_exchange_chart
+
+ schedule_journal_exchange_chart(
+ entry_id=entry_id,
+ exchange_symbol=chart_job["exchange_symbol"],
+ title_prefix=chart_job["title_prefix"],
+ journal_tfs=chart_job["journal_tfs"],
+ journal_limit=chart_job["journal_limit"],
+ marker_payload=chart_job["marker_payload"],
+ upload_folder=app.config["UPLOAD_FOLDER"],
+ generate_chart_fn=generate_multi_timeframe_chart_png,
+ get_db_fn=get_db,
+ )
+ msg = (
+ "交易复盘记录已保存.K 线图后台生成中"
+ f"({'/'.join(chart_job['journal_tfs'])} 各{chart_job['journal_limit']}根),稍后刷新列表可见."
+ )
else:
- flash("交易复盘记录已保存")
+ msg = "交易复盘记录已保存"
+ from lib.instance.journal_chart_async_lib import request_wants_journal_ajax
+
+ if request_wants_journal_ajax(request):
+ return jsonify(
+ {
+ "ok": True,
+ "id": entry_id,
+ "msg": msg,
+ "chart_pending": bool(chart_job),
+ }
+ )
+ flash(msg)
return _redirect_records()
diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py
index 4c678fb..36ba081 100644
--- a/crypto_monitor_okx/app.py
+++ b/crypto_monitor_okx/app.py
@@ -8857,29 +8857,28 @@ def del_order(id):
@app.route("/add_journal", methods=["POST"])
@login_required
def add_journal():
+ from lib.instance.journal_chart_async_lib import journal_ajax_or_flash_error
+
d = request.form
order_type_norm = normalize_journal_order_type(d.get("order_type"))
if not order_type_norm:
- flash("请选择下单类型")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择下单类型", redirect_fn=_redirect_records)
direction_norm = normalize_journal_direction(d.get("direction") or d.get("direction_hint"))
if not direction_norm:
- flash("请选择方向")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择方向", redirect_fn=_redirect_records)
entry_reason_norm = normalize_journal_entry_reason(
d.get("entry_reason"), ENTRY_REASON_OPTIONS, allow_legacy=False
)
if not entry_reason_norm:
- flash("请选择开仓类型")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择开仓类型", redirect_fn=_redirect_records)
early_exit_trigger = normalize_early_exit_trigger(d.get("early_exit_trigger"))
early_exit_note = str(d.get("early_exit_note") or "").strip()
if not early_exit_trigger:
- flash("请选择离场触发")
- return _redirect_records()
+ return journal_ajax_or_flash_error(request, "请选择离场触发", redirect_fn=_redirect_records)
if early_exit_trigger == "手动平仓" and not early_exit_note:
- flash("手工平仓必须填写补充说明")
- return _redirect_records()
+ return journal_ajax_or_flash_error(
+ request, "手工平仓必须填写补充说明", redirect_fn=_redirect_records
+ )
if early_exit_trigger != "手动平仓":
early_exit_note = ""
# 兼容字段:仅「手工平仓」记为「主观提前」语义下的「是」
@@ -8912,14 +8911,14 @@ def add_journal():
want_exchange_chart = (
not has_manual_uploads
+ and ORDER_CHART_ENABLED
and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes")
)
- chart_msg = None
- if want_exchange_chart and ORDER_CHART_ENABLED:
+ chart_job = None
+ if want_exchange_chart:
coin = (d.get("coin") or "").strip().upper()
symbol_guess = normalize_symbol_input(coin) or coin
exchange_symbol = normalize_okx_symbol(symbol_guess)
- title_prefix = f"{symbol_guess} journal {entry_id[:8]}"
journal_tfs = parse_journal_chart_timeframes(
d.get("journal_chart_tf1"),
d.get("journal_chart_tf2"),
@@ -8927,36 +8926,21 @@ def add_journal():
)
journal_limit = parse_journal_chart_limit(d.get("journal_chart_limit"), ORDER_CHART_LIMIT)
chart_anchor = parse_journal_chart_anchor(d.get("journal_chart_anchor"))
- marker_payload = {
- "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
- "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
- "entry_price": d.get("entry_price_hint"),
- "exit_price": d.get("exit_price_hint"),
- "stop_loss_price": d.get("stop_loss_hint"),
- "chart_anchor": chart_anchor,
- "now_ts_ms": int(app_now().timestamp() * 1000),
+ chart_job = {
+ "exchange_symbol": exchange_symbol,
+ "title_prefix": f"{symbol_guess} journal {entry_id[:8]}",
+ "journal_tfs": journal_tfs,
+ "journal_limit": journal_limit,
+ "marker_payload": {
+ "entry_ts_ms": _local_input_datetime_to_ms(d.get("open_datetime")),
+ "exit_ts_ms": _local_input_datetime_to_ms(d.get("close_datetime")),
+ "entry_price": d.get("entry_price_hint"),
+ "exit_price": d.get("exit_price_hint"),
+ "stop_loss_price": d.get("stop_loss_hint"),
+ "chart_anchor": chart_anchor,
+ "now_ts_ms": int(app_now().timestamp() * 1000),
+ },
}
- try:
- chart_fname = f"journal_{entry_id}.png"
- saved = generate_multi_timeframe_chart_png(
- exchange_symbol,
- title_prefix,
- timeframes=journal_tfs,
- limit=journal_limit,
- out_dir=app.config["UPLOAD_FOLDER"],
- filename=chart_fname,
- filename_prefix="journal",
- marker_payload=marker_payload,
- marker_timeframes={x.strip().lower() for x in journal_tfs},
- layout="vertical",
- )
- if saved:
- image_filename = saved
- chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}"
- else:
- chart_msg = "已勾选自动生成K线图,但生成失败(返回空).请检查 Pillow 是否安装,OKX 网络/代理是否正常."
- except Exception as e:
- chart_msg = f"自动生成K线图失败:{str(e)}"
conn = get_db()
conn.execute(
@@ -8993,10 +8977,38 @@ def add_journal():
)
conn.commit()
conn.close()
- if chart_msg:
- flash(f"交易复盘记录已保存.{chart_msg}")
+ if chart_job:
+ from lib.instance.journal_chart_async_lib import schedule_journal_exchange_chart
+
+ schedule_journal_exchange_chart(
+ entry_id=entry_id,
+ exchange_symbol=chart_job["exchange_symbol"],
+ title_prefix=chart_job["title_prefix"],
+ journal_tfs=chart_job["journal_tfs"],
+ journal_limit=chart_job["journal_limit"],
+ marker_payload=chart_job["marker_payload"],
+ upload_folder=app.config["UPLOAD_FOLDER"],
+ generate_chart_fn=generate_multi_timeframe_chart_png,
+ get_db_fn=get_db,
+ )
+ msg = (
+ "交易复盘记录已保存.K 线图后台生成中"
+ f"({'/'.join(chart_job['journal_tfs'])} 各{chart_job['journal_limit']}根),稍后刷新列表可见."
+ )
else:
- flash("交易复盘记录已保存")
+ msg = "交易复盘记录已保存"
+ from lib.instance.journal_chart_async_lib import request_wants_journal_ajax
+
+ if request_wants_journal_ajax(request):
+ return jsonify(
+ {
+ "ok": True,
+ "id": entry_id,
+ "msg": msg,
+ "chart_pending": bool(chart_job),
+ }
+ )
+ flash(msg)
return _redirect_records()
diff --git a/lib/common/static/journal_form_save.js b/lib/common/static/journal_form_save.js
new file mode 100644
index 0000000..822ec09
--- /dev/null
+++ b/lib/common/static/journal_form_save.js
@@ -0,0 +1,142 @@
+/**
+ * 复盘表单 AJAX 保存:避免整页刷新卡顿;配合 FormSubmitGuard 即时反馈.
+ */
+(function (global) {
+ "use strict";
+
+ function $(id) {
+ return document.getElementById(id);
+ }
+
+ function toast(msg) {
+ if (!msg) return;
+ try {
+ if (global.InstanceTheme && typeof InstanceTheme.toast === "function") {
+ InstanceTheme.toast(msg);
+ return;
+ }
+ } catch (_) {}
+ try {
+ alert(msg);
+ } catch (_) {}
+ }
+
+ function refreshLists() {
+ if (typeof global.loadJournals === "function") {
+ try {
+ global.loadJournals();
+ } catch (_) {}
+ } else if (global.RecordsReviewPage && typeof RecordsReviewPage.loadJournals === "function") {
+ try {
+ RecordsReviewPage.loadJournals();
+ } catch (_) {}
+ }
+ }
+
+ function resetJournalForm(form) {
+ if (!form) return;
+ form.reset();
+ ["risk-amount-hint", "entry-price-hint", "stop-loss-hint", "exit-price-hint", "direction-hint"].forEach(
+ function (id) {
+ var el = $(id);
+ if (el) el.value = "";
+ }
+ );
+ if (global.JournalUploadSlots && typeof JournalUploadSlots.reset === "function") {
+ JournalUploadSlots.reset(form);
+ }
+ if (typeof global.syncEarlyExitNoteRequired === "function") {
+ try {
+ global.syncEarlyExitNoteRequired();
+ } catch (_) {}
+ }
+ if (global.RecordsReviewPage && typeof RecordsReviewPage.hideJournalCard === "function") {
+ try {
+ RecordsReviewPage.hideJournalCard();
+ } catch (_) {}
+ }
+ }
+
+ function onSuccess(form, data) {
+ var msg = (data && data.msg) || "交易复盘记录已保存";
+ toast(msg);
+ resetJournalForm(form);
+ refreshLists();
+ if (data && data.chart_pending) {
+ // K 线后台生成,数秒后再刷一次列表拿缩略图
+ setTimeout(refreshLists, 4000);
+ setTimeout(refreshLists, 12000);
+ }
+ }
+
+ function bind(form) {
+ if (!form || form.dataset.journalAjaxBound === "1") return;
+ form.dataset.journalAjaxBound = "1";
+ form.addEventListener("submit", function (ev) {
+ if (typeof global.validateJournalEntryReason === "function") {
+ if (!global.validateJournalEntryReason()) {
+ ev.preventDefault();
+ return;
+ }
+ }
+ ev.preventDefault();
+ if (global.FormSubmitGuard && FormSubmitGuard.isLocked(form)) return;
+ if (global.FormSubmitGuard) FormSubmitGuard.lock(form, "保存中…");
+
+ var fd = new FormData(form);
+ fd.set("ajax", "1");
+ var action = form.getAttribute("action") || "/add_journal";
+
+ fetch(action, {
+ method: "POST",
+ body: fd,
+ credentials: "same-origin",
+ headers: {
+ "X-Requested-With": "XMLHttpRequest",
+ Accept: "application/json",
+ },
+ })
+ .then(function (res) {
+ var ct = (res.headers.get("content-type") || "").toLowerCase();
+ if (ct.indexOf("application/json") >= 0) {
+ return res.json().then(function (data) {
+ return { ok: res.ok, data: data, redirected: false };
+ });
+ }
+ // 旧后端未返回 JSON 时走整页逻辑
+ if (res.redirected || res.ok) {
+ global.location.reload();
+ return { ok: true, data: null, redirected: true };
+ }
+ throw new Error("save failed");
+ })
+ .then(function (result) {
+ if (result.redirected) return;
+ if (!result.ok || !result.data || result.data.ok === false) {
+ var err =
+ (result.data && (result.data.msg || result.data.error)) || "保存失败";
+ throw new Error(err);
+ }
+ onSuccess(form, result.data);
+ })
+ .catch(function (err) {
+ toast((err && err.message) || "保存失败,请稍后重试");
+ })
+ .finally(function () {
+ if (global.FormSubmitGuard) FormSubmitGuard.unlock(form);
+ });
+ });
+ }
+
+ function init() {
+ bind($("journal-form"));
+ }
+
+ global.JournalFormSave = { init: init, bind: bind };
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init);
+ } else {
+ init();
+ }
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py
index 8f682e5..4cfb1a5 100644
--- a/lib/hub/hub_bridge.py
+++ b/lib/hub/hub_bridge.py
@@ -66,6 +66,7 @@ def install_instance_theme_static(app) -> None:
"manual_order_rr_preview.js": "application/javascript; charset=utf-8",
"symbol_live_price.js": "application/javascript; charset=utf-8",
"journal_upload_slots.js": "application/javascript; charset=utf-8",
+ "journal_form_save.js": "application/javascript; charset=utf-8",
"strategy_roll.js": "application/javascript; charset=utf-8",
"instance_page.css": "text/css; charset=utf-8",
"instance_embed.js": "application/javascript; charset=utf-8",
diff --git a/lib/instance/journal_chart_async_lib.py b/lib/instance/journal_chart_async_lib.py
new file mode 100644
index 0000000..bdd01d1
--- /dev/null
+++ b/lib/instance/journal_chart_async_lib.py
@@ -0,0 +1,93 @@
+"""复盘自动 K 线:后台线程生成,避免 /add_journal 同步卡住."""
+
+from __future__ import annotations
+
+import logging
+import threading
+from typing import Any, Callable
+
+logger = logging.getLogger(__name__)
+
+
+def schedule_journal_exchange_chart(
+ *,
+ entry_id: str,
+ exchange_symbol: str,
+ title_prefix: str,
+ journal_tfs: list[str],
+ journal_limit: int,
+ marker_payload: dict[str, Any],
+ upload_folder: str,
+ generate_chart_fn: Callable[..., str | None],
+ get_db_fn: Callable[[], Any],
+) -> None:
+ """提交后立即返回;线程内画图并写回 journal_entries.image."""
+ entry_id = str(entry_id or "").strip()
+ if not entry_id or not callable(generate_chart_fn) or not callable(get_db_fn):
+ return
+
+ tfs = [str(x).strip() for x in (journal_tfs or []) if str(x).strip()]
+ if not tfs:
+ return
+
+ def _run() -> None:
+ try:
+ chart_fname = f"journal_{entry_id}.png"
+ saved = generate_chart_fn(
+ exchange_symbol,
+ title_prefix,
+ timeframes=tfs,
+ limit=journal_limit,
+ out_dir=upload_folder,
+ filename=chart_fname,
+ filename_prefix="journal",
+ marker_payload=marker_payload,
+ marker_timeframes={x.lower() for x in tfs},
+ layout="vertical",
+ )
+ if not saved:
+ logger.warning("journal chart async empty entry_id=%s", entry_id)
+ return
+ conn = get_db_fn()
+ try:
+ conn.execute(
+ "UPDATE journal_entries SET image=? WHERE id=?",
+ (saved, entry_id),
+ )
+ conn.commit()
+ finally:
+ conn.close()
+ except Exception:
+ logger.exception("journal chart async failed entry_id=%s", entry_id)
+
+ threading.Thread(
+ target=_run,
+ name=f"journal-chart-{entry_id[:8]}",
+ daemon=True,
+ ).start()
+
+
+def request_wants_journal_ajax(request: Any) -> bool:
+ """XHR / Accept:json / form ajax=1 → 返回 JSON,避免整页刷新."""
+ xrw = str(getattr(request, "headers", {}).get("X-Requested-With") or "").lower()
+ if xrw == "xmlhttprequest":
+ return True
+ form = getattr(request, "form", None)
+ if form is not None:
+ raw = str(form.get("ajax") or "").strip().lower()
+ if raw in ("1", "true", "yes", "on"):
+ return True
+ accept = str(getattr(request, "headers", {}).get("Accept") or "").lower()
+ if "application/json" in accept and accept.strip().startswith("application/json"):
+ return True
+ return False
+
+
+def journal_ajax_or_flash_error(request: Any, msg: str, *, redirect_fn: Callable[[], Any]):
+ """校验失败:AJAX 返回 JSON,否则 flash + 跳转."""
+ from flask import flash, jsonify
+
+ if request_wants_journal_ajax(request):
+ return jsonify({"ok": False, "msg": msg}), 400
+ flash(msg)
+ return redirect_fn()
diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html
index ecbc41f..62827b7 100644
--- a/lib/instance/templates/embed_boot_scripts.html
+++ b/lib/instance/templates/embed_boot_scripts.html
@@ -1318,13 +1318,6 @@ if(window.ManualOrderRrPreview){
refreshAccountSnapshot();
if (window.AccountRiskBadge) AccountRiskBadge.startTicker();
-const _journalFormEl = document.getElementById("journal-form");
-if(_journalFormEl){
- _journalFormEl.addEventListener("submit", function(ev){
- if(!validateJournalEntryReason()) ev.preventDefault();
- });
-}
-
const addOrderForm = document.getElementById("add-order-form");
if(addOrderForm){
addOrderForm.addEventListener("submit", function(ev){
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html
index 2e36a8f..c0c2152 100644
--- a/lib/instance/templates/embed_shell.html
+++ b/lib/instance/templates/embed_shell.html
@@ -97,6 +97,7 @@
+
+