From 88fe4bbe566f0a1d28e32f52615eda8a4ceec912 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 5 Jul 2026 21:57:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=8D=E7=9B=98=E5=9B=9B=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E6=89=8B=E5=8A=A8=E4=B8=8A=E4=BC=A0=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20AI=20=E8=AF=86=E5=88=AB=E9=A2=84=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定 5m/15m/1h/4h 四槽截图上传与详情四宫格展示;自动 K 线默认关闭且与手动上传互斥。 Co-authored-by: Cursor --- crypto_monitor_binance/app.py | 158 +++++------------- crypto_monitor_binance/templates/index.html | 67 +------- crypto_monitor_gate/app.py | 158 +++++------------- crypto_monitor_gate/templates/index.html | 67 +------- crypto_monitor_okx/app.py | 158 +++++------------- crypto_monitor_okx/templates/index.html | 67 +------- lib/ai/ai_review_lib.py | 32 ++-- lib/common/static/instance_embed.js | 4 + lib/common/static/instance_theme.css | 105 ++++++++++++ lib/common/static/instance_ui.js | 83 +++++++-- lib/common/static/journal_upload_slots.js | 69 ++++++++ lib/hub/hub_bridge.py | 1 + lib/instance/journal_images_lib.py | 119 +++++++++++++ .../templates/embed_boot_scripts.html | 55 ------ .../templates/embed_page_fragment.html | 8 +- lib/instance/templates/embed_shell.html | 4 +- .../templates/journal_upload_slots.html | 19 +++ tests/test_journal_images_lib.py | 91 ++++++++++ 18 files changed, 644 insertions(+), 621 deletions(-) create mode 100644 lib/common/static/journal_upload_slots.js create mode 100644 lib/instance/journal_images_lib.py create mode 100644 lib/strategy/templates/journal_upload_slots.html create mode 100644 tests/test_journal_images_lib.py diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index e12caa2..d882746 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -75,6 +75,13 @@ from lib.strategy.strategy_trade_labels import ( trade_record_monitor_type as resolve_trade_record_monitor_type, trend_plan_id_from_monitor_row, ) +from lib.instance.journal_images_lib import ( + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + primary_journal_image, + save_journal_slot_uploads, +) from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -1147,75 +1154,6 @@ def journal_exit_reason_stored(trigger, note): return t -def ai_extract_journal_from_image(image_b64): - prompt = """ -你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 -要求: -1) 仅输出一个 JSON 对象。 -2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 -3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 -4) 允许字段为空。 -5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): - - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 - - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 - - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 - - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 - - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 -6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):止盈、保本止盈、移动止盈、时间平仓、手动平仓、止损、其他。 -7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 -8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 - -JSON 字段: -{ - "open_datetime": "", - "close_datetime": "", - "coin": "", - "tf": "", - "pnl": "", - "expect_rr": "", - "real_rr": "", - "entry_reason": "", - "early_exit_trigger": "", - "early_exit_note": "", - "early_exit_reason": "", - "note": "" -} -""".strip() - try: - raw = ai_generate(prompt, images_b64=[image_b64], temperature=0.1) - if raw.startswith("AI 调用失败"): - return {} - data = _extract_json_object(raw) or {} - if not isinstance(data, dict): - data = {} - trig_in = data.get("early_exit_trigger") - note_in = data.get("early_exit_note") - legacy_reason = str(data.get("early_exit_reason") or "").strip() - out = { - "open_datetime": str(data.get("open_datetime") or "").strip(), - "close_datetime": str(data.get("close_datetime") or "").strip(), - "coin": str(data.get("coin") or "").strip(), - "tf": str(data.get("tf") or "").strip(), - "pnl": str(data.get("pnl") or "").strip(), - "expect_rr": str(data.get("expect_rr") or "").strip(), - "real_rr": str(data.get("real_rr") or "").strip(), - "entry_reason": normalize_entry_reason(data.get("entry_reason")), - "early_exit_trigger": normalize_early_exit_trigger(trig_in), - "early_exit_note": str(note_in or "").strip(), - "early_exit_reason": legacy_reason, - "note": str(data.get("note") or "").strip(), - } - if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] != "手动平仓": - out["early_exit_note"] = "" - out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) - return out - except Exception: - return None - # 初始化数据库(支持多空方向) def init_db(): conn = sqlite3.connect(DB_PATH) @@ -1465,6 +1403,9 @@ def init_db(): try: c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT") + except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass @@ -8871,7 +8812,7 @@ def export_journal_entries(): rows = conn.execute( "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," - "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + "post_breakeven_stare,new_trade_while_occupied,note,image,images_json,created_at FROM journal_entries ORDER BY created_at ASC" ).fetchall() conn.close() head = [ @@ -8895,6 +8836,7 @@ def export_journal_entries(): "new_trade_while_occupied", "note", "image", + "images_json", "created_at", ] data = [tuple(r[h] for h in head) for r in rows] @@ -9196,16 +9138,16 @@ def add_journal(): early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) - image_filename = None - uploaded_tmp = None entry_id = uuid.uuid4().hex - file = request.files.get("screenshot") - if file and file.filename: - ext = os.path.splitext(file.filename)[1] - image_filename = f"{uuid.uuid4().hex}{ext}" - save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) - file.save(save_path) - uploaded_tmp = image_filename + manual_images = save_journal_slot_uploads( + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) mood_issues = ",".join(request.form.getlist("mood_issues")) hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) @@ -9219,7 +9161,10 @@ def add_journal(): except Exception: pass - want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + want_exchange_chart = ( + not has_manual_uploads + and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + ) chart_msg = None if want_exchange_chart and ORDER_CHART_ENABLED: coin = (d.get("coin") or "").strip().upper() @@ -9259,17 +9204,9 @@ def add_journal(): if saved: image_filename = saved chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" - if uploaded_tmp: - try: - old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) - if os.path.exists(old_path): - os.remove(old_path) - except Exception: - pass else: chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Binance 网络/代理是否正常。" except Exception as e: - image_filename = uploaded_tmp chart_msg = f"自动生成K线图失败:{str(e)}" conn = get_db() @@ -9278,8 +9215,8 @@ def add_journal(): (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + new_trade_while_occupied, note, image, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( entry_id, normalize_bj_datetime_storage(d.get("open_datetime")), @@ -9290,7 +9227,8 @@ def add_journal(): d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, None, None, None, mood_issues, - d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename, + images_json_str, ) ) from lib.trade.account_risk_lib import on_journal_saved @@ -9326,41 +9264,27 @@ def api_journals(): conn.close() result = [] for r in rows: - item = row_to_dict(r) + item = enrich_journal_api_item(row_to_dict(r)) item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] result.append(item) return jsonify(result) -@app.route("/api/journal_prefill", methods=["POST"]) -@login_required -def api_journal_prefill(): - file = request.files.get("screenshot") - if not file or not file.filename: - return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 - try: - raw = file.read() - if not raw: - return jsonify({"ok": False, "msg": "截图为空"}), 400 - image_b64 = base64.b64encode(raw).decode("utf-8") - except Exception as e: - return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 - - parsed = ai_extract_journal_from_image(image_b64) - if parsed is None: - return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 - return jsonify({"ok": True, "data": parsed}) - - @app.route("/delete_journal/", methods=["POST"]) @login_required def delete_journal(jid): conn = get_db() - row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() - if row and row["image"]: - img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) - if os.path.exists(img_path): - os.remove(img_path) + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) conn.commit() conn.close() diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 8f37208..4a547bc 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -702,11 +702,12 @@ - + {% from 'journal_upload_slots.html' import journal_upload_slots %} + {{ journal_upload_slots() }}
@@ -734,9 +735,6 @@
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
- -
@@ -841,11 +839,13 @@
+ - + + @@ -1430,61 +1430,6 @@ function fillJournalFromTrade(t){ alert("已填入下方复盘表单,请手动补充主观原因。"); } -function prefillJournalByImage(){ - const fileInput = document.getElementById("journal-screenshot"); - if(!fileInput || !fileInput.files || !fileInput.files.length){ - alert("请先选择截图"); - return; - } - const fd = new FormData(); - fd.append("screenshot", fileInput.files[0]); - fetch("/api/journal_prefill", { method: "POST", body: fd }) - .then(r=>r.json()) - .then(res=>{ - if(!res.ok){ - alert(res.msg || "AI识别失败"); - return; - } - const d = res.data || {}; - setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime)); - setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime)); - setJournalField("coin", d.coin || ""); - setJournalField("tf", d.tf || ""); - setJournalField("pnl", d.pnl || ""); - setJournalField("expect_rr", d.expect_rr || ""); - setJournalField("real_rr", d.real_rr || ""); - let entryReason = String(d.entry_reason || "").trim(); - let customEr = ""; - if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){ - // keep - } else if(entryReason){ - customEr = entryReason; - entryReason = JOURNAL_ENTRY_REASON_OTHER; - } else { - entryReason = ""; - } - setJournalField("entry_reason", entryReason); - setJournalField("entry_reason_custom", customEr); - syncJournalEntryReasonOtherUi(); - const trig = (d.early_exit_trigger || "").trim(); - let noteEx = (d.early_exit_note || "").trim(); - const legacy = (d.early_exit_reason || "").trim(); - if(!noteEx && legacy && !trig){ - const sp = splitLegacyEarlyExitReason(legacy); - setJournalField("early_exit_trigger", sp.trigger); - setJournalField("early_exit_note", sp.note); - } else { - setJournalField("early_exit_trigger", trig); - setJournalField("early_exit_note", noteEx); - } - setJournalField("note", d.note || ""); - if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired(); - recomputeJournalRealRr(); - alert("已完成预填,请手动检查并补充原因"); - }) - .catch(()=>alert("AI识别请求失败")); -} - function recomputeJournalRealRr(){ const form = document.getElementById("journal-form"); if(!form) return; diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 3296ba9..c16a7e4 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -76,6 +76,13 @@ from lib.strategy.strategy_trade_labels import ( trade_record_monitor_type as resolve_trade_record_monitor_type, trend_plan_id_from_monitor_row, ) +from lib.instance.journal_images_lib import ( + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + primary_journal_image, + save_journal_slot_uploads, +) from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -1131,75 +1138,6 @@ def journal_exit_reason_stored(trigger, note): return t -def ai_extract_journal_from_image(image_b64): - prompt = """ -你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 -要求: -1) 仅输出一个 JSON 对象。 -2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 -3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 -4) 允许字段为空。 -5) entry_reason:优先从下列完整字符串中选一个(一字不差);若无法归类则可将简述写入 entry_reason(保存时也可选表单「其他」手写): - - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 - - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 - - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 - - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 - - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 -6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):止盈、保本止盈、移动止盈、时间平仓、手动平仓、止损、其他。 -7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 -8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 - -JSON 字段: -{ - "open_datetime": "", - "close_datetime": "", - "coin": "", - "tf": "", - "pnl": "", - "expect_rr": "", - "real_rr": "", - "entry_reason": "", - "early_exit_trigger": "", - "early_exit_note": "", - "early_exit_reason": "", - "note": "" -} -""".strip() - try: - raw = ai_generate(prompt, images_b64=[image_b64], temperature=0.1) - if raw.startswith("AI 调用失败"): - return {} - data = _extract_json_object(raw) or {} - if not isinstance(data, dict): - data = {} - trig_in = data.get("early_exit_trigger") - note_in = data.get("early_exit_note") - legacy_reason = str(data.get("early_exit_reason") or "").strip() - out = { - "open_datetime": str(data.get("open_datetime") or "").strip(), - "close_datetime": str(data.get("close_datetime") or "").strip(), - "coin": str(data.get("coin") or "").strip(), - "tf": str(data.get("tf") or "").strip(), - "pnl": str(data.get("pnl") or "").strip(), - "expect_rr": str(data.get("expect_rr") or "").strip(), - "real_rr": str(data.get("real_rr") or "").strip(), - "entry_reason": normalize_entry_reason(data.get("entry_reason")), - "early_exit_trigger": normalize_early_exit_trigger(trig_in), - "early_exit_note": str(note_in or "").strip(), - "early_exit_reason": legacy_reason, - "note": str(data.get("note") or "").strip(), - } - if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] != "手动平仓": - out["early_exit_note"] = "" - out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) - return out - except Exception: - return None - # 初始化数据库(支持多空方向) def init_db(): conn = sqlite3.connect(DB_PATH) @@ -1454,6 +1392,9 @@ def init_db(): try: c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT") + except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass @@ -8730,7 +8671,7 @@ def export_journal_entries(): rows = conn.execute( "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," - "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + "post_breakeven_stare,new_trade_while_occupied,note,image,images_json,created_at FROM journal_entries ORDER BY created_at ASC" ).fetchall() conn.close() head = [ @@ -8754,6 +8695,7 @@ def export_journal_entries(): "new_trade_while_occupied", "note", "image", + "images_json", "created_at", ] data = [tuple(r[h] for h in head) for r in rows] @@ -9036,16 +8978,16 @@ def add_journal(): early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) - image_filename = None - uploaded_tmp = None entry_id = uuid.uuid4().hex - file = request.files.get("screenshot") - if file and file.filename: - ext = os.path.splitext(file.filename)[1] - image_filename = f"{uuid.uuid4().hex}{ext}" - save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) - file.save(save_path) - uploaded_tmp = image_filename + manual_images = save_journal_slot_uploads( + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) mood_issues = ",".join(request.form.getlist("mood_issues")) hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) @@ -9059,7 +9001,10 @@ def add_journal(): except Exception: pass - want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + want_exchange_chart = ( + not has_manual_uploads + and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + ) chart_msg = None if want_exchange_chart and ORDER_CHART_ENABLED: coin = (d.get("coin") or "").strip().upper() @@ -9099,17 +9044,9 @@ def add_journal(): if saved: image_filename = saved chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" - if uploaded_tmp: - try: - old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) - if os.path.exists(old_path): - os.remove(old_path) - except Exception: - pass else: chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、Gate 网络/代理是否正常。" except Exception as e: - image_filename = uploaded_tmp chart_msg = f"自动生成K线图失败:{str(e)}" conn = get_db() @@ -9118,8 +9055,8 @@ def add_journal(): (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + new_trade_while_occupied, note, image, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( entry_id, normalize_bj_datetime_storage(d.get("open_datetime")), @@ -9130,7 +9067,8 @@ def add_journal(): d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, None, None, None, mood_issues, - d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename, + images_json_str, ) ) from lib.trade.account_risk_lib import on_journal_saved @@ -9166,41 +9104,27 @@ def api_journals(): conn.close() result = [] for r in rows: - item = row_to_dict(r) + item = enrich_journal_api_item(row_to_dict(r)) item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] result.append(item) return jsonify(result) -@app.route("/api/journal_prefill", methods=["POST"]) -@login_required -def api_journal_prefill(): - file = request.files.get("screenshot") - if not file or not file.filename: - return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 - try: - raw = file.read() - if not raw: - return jsonify({"ok": False, "msg": "截图为空"}), 400 - image_b64 = base64.b64encode(raw).decode("utf-8") - except Exception as e: - return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 - - parsed = ai_extract_journal_from_image(image_b64) - if parsed is None: - return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 - return jsonify({"ok": True, "data": parsed}) - - @app.route("/delete_journal/", methods=["POST"]) @login_required def delete_journal(jid): conn = get_db() - row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() - if row and row["image"]: - img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) - if os.path.exists(img_path): - os.remove(img_path) + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) conn.commit() conn.close() diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index 9b9fbf5..237e037 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -669,11 +669,12 @@ - + {% from 'journal_upload_slots.html' import journal_upload_slots %} + {{ journal_upload_slots() }}
@@ -701,9 +702,6 @@
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
- -
@@ -808,11 +806,13 @@
+ - + + @@ -1397,61 +1397,6 @@ function fillJournalFromTrade(t){ alert("已填入下方复盘表单,请手动补充主观原因。"); } -function prefillJournalByImage(){ - const fileInput = document.getElementById("journal-screenshot"); - if(!fileInput || !fileInput.files || !fileInput.files.length){ - alert("请先选择截图"); - return; - } - const fd = new FormData(); - fd.append("screenshot", fileInput.files[0]); - fetch("/api/journal_prefill", { method: "POST", body: fd }) - .then(r=>r.json()) - .then(res=>{ - if(!res.ok){ - alert(res.msg || "AI识别失败"); - return; - } - const d = res.data || {}; - setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime)); - setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime)); - setJournalField("coin", d.coin || ""); - setJournalField("tf", d.tf || ""); - setJournalField("pnl", d.pnl || ""); - setJournalField("expect_rr", d.expect_rr || ""); - setJournalField("real_rr", d.real_rr || ""); - let entryReason = String(d.entry_reason || "").trim(); - let customEr = ""; - if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){ - // keep - } else if(entryReason){ - customEr = entryReason; - entryReason = JOURNAL_ENTRY_REASON_OTHER; - } else { - entryReason = ""; - } - setJournalField("entry_reason", entryReason); - setJournalField("entry_reason_custom", customEr); - syncJournalEntryReasonOtherUi(); - const trig = (d.early_exit_trigger || "").trim(); - let noteEx = (d.early_exit_note || "").trim(); - const legacy = (d.early_exit_reason || "").trim(); - if(!noteEx && legacy && !trig){ - const sp = splitLegacyEarlyExitReason(legacy); - setJournalField("early_exit_trigger", sp.trigger); - setJournalField("early_exit_note", sp.note); - } else { - setJournalField("early_exit_trigger", trig); - setJournalField("early_exit_note", noteEx); - } - setJournalField("note", d.note || ""); - if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired(); - recomputeJournalRealRr(); - alert("已完成预填,请手动检查并补充原因"); - }) - .catch(()=>alert("AI识别请求失败")); -} - function recomputeJournalRealRr(){ const form = document.getElementById("journal-form"); if(!form) return; diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 9f13bdc..f0881f0 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -76,6 +76,13 @@ from lib.strategy.strategy_trade_labels import ( trend_plan_id_from_monitor_row, ) from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders +from lib.instance.journal_images_lib import ( + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + primary_journal_image, + save_journal_slot_uploads, +) from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -1124,75 +1131,6 @@ def journal_exit_reason_stored(trigger, note): return t -def ai_extract_journal_from_image(image_b64): - prompt = """ -你是交易复盘信息提取助手。请从截图中提取可识别字段,并只输出 JSON(不要 markdown,不要解释)。 -要求: -1) 仅输出一个 JSON 对象。 -2) 时间输出为 YYYY-MM-DDTHH:MM(用于 HTML datetime-local),无法识别填空字符串。 -3) 不要猜测主观原因;early_exit_note(仅手工平仓)、note 默认留空,除非图中明确写出。 -4) 允许字段为空。 -5) entry_reason 只能从下列完整字符串中选一个(一字不差;截图无法归类则填空字符串): - - 趋势多头:4h大结构突破前进场,确认条件:三次探顶,5m收敛不创新低 - - 趋势空头:4h大结构突破前进场,确认条件:三次探底,5m收敛不创新高 - - 趋势多头:小分歧低吸入场(左侧),确认条件:二次探底 - - 趋势空头:小分歧高吸入场(左侧),确认条件:二次探顶 - - 波段单:5m顺势突破,确认条件:2根k线+成交量放大+4h同向+日成交量前20 -6) early_exit_trigger 只能从下列取值中选一个(无法识别则填空字符串):止盈、保本止盈、移动止盈、时间平仓、手动平仓、止损、其他。 -7) 若触发为「手动平仓」,early_exit_note 必须写出图中可见的补充说明;其他触发类型 early_exit_note 留空。 -8) 若图中有无法归类的离场说明原文,可放进 early_exit_note,early_exit_trigger 填「其他」或留空。 - -JSON 字段: -{ - "open_datetime": "", - "close_datetime": "", - "coin": "", - "tf": "", - "pnl": "", - "expect_rr": "", - "real_rr": "", - "entry_reason": "", - "early_exit_trigger": "", - "early_exit_note": "", - "early_exit_reason": "", - "note": "" -} -""".strip() - try: - raw = ai_generate(prompt, images_b64=[image_b64], temperature=0.1) - if raw.startswith("AI 调用失败"): - return {} - data = _extract_json_object(raw) or {} - if not isinstance(data, dict): - data = {} - trig_in = data.get("early_exit_trigger") - note_in = data.get("early_exit_note") - legacy_reason = str(data.get("early_exit_reason") or "").strip() - out = { - "open_datetime": str(data.get("open_datetime") or "").strip(), - "close_datetime": str(data.get("close_datetime") or "").strip(), - "coin": str(data.get("coin") or "").strip(), - "tf": str(data.get("tf") or "").strip(), - "pnl": str(data.get("pnl") or "").strip(), - "expect_rr": str(data.get("expect_rr") or "").strip(), - "real_rr": str(data.get("real_rr") or "").strip(), - "entry_reason": normalize_entry_reason(data.get("entry_reason")), - "early_exit_trigger": normalize_early_exit_trigger(trig_in), - "early_exit_note": str(note_in or "").strip(), - "early_exit_reason": legacy_reason, - "note": str(data.get("note") or "").strip(), - } - if not out["early_exit_trigger"] and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] == "手动平仓" and not out["early_exit_note"] and legacy_reason: - out["early_exit_note"] = legacy_reason - if out["early_exit_trigger"] != "手动平仓": - out["early_exit_note"] = "" - out["exit_reason"] = journal_exit_reason_stored(out["early_exit_trigger"], out["early_exit_note"]) - return out - except Exception: - return None - # 初始化数据库(支持多空方向) def init_db(): conn = sqlite3.connect(DB_PATH) @@ -1444,6 +1382,9 @@ def init_db(): try: c.execute("ALTER TABLE journal_entries ADD COLUMN early_exit_note TEXT") except: pass + try: + c.execute("ALTER TABLE journal_entries ADD COLUMN images_json TEXT") + except: pass try: c.execute("ALTER TABLE key_monitors ADD COLUMN direction TEXT DEFAULT 'long'") except: pass @@ -8279,7 +8220,7 @@ def export_journal_entries(): rows = conn.execute( "SELECT id,open_datetime,close_datetime,hold_duration,coin,tf,pnl,entry_reason,exit_reason," "expect_rr,real_rr,early_exit,early_exit_trigger,early_exit_note,early_exit_reason,mood_issues," - "post_breakeven_stare,new_trade_while_occupied,note,image,created_at FROM journal_entries ORDER BY created_at ASC" + "post_breakeven_stare,new_trade_while_occupied,note,image,images_json,created_at FROM journal_entries ORDER BY created_at ASC" ).fetchall() conn.close() head = [ @@ -8303,6 +8244,7 @@ def export_journal_entries(): "new_trade_while_occupied", "note", "image", + "images_json", "created_at", ] data = [tuple(r[h] for h in head) for r in rows] @@ -8567,16 +8509,16 @@ def add_journal(): early_exit_raw = "是" if early_exit_trigger == "手动平仓" else "否" early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note) - image_filename = None - uploaded_tmp = None entry_id = uuid.uuid4().hex - file = request.files.get("screenshot") - if file and file.filename: - ext = os.path.splitext(file.filename)[1] - image_filename = f"{uuid.uuid4().hex}{ext}" - save_path = os.path.join(app.config["UPLOAD_FOLDER"], secure_filename(image_filename)) - file.save(save_path) - uploaded_tmp = image_filename + manual_images = save_journal_slot_uploads( + request.files, + entry_id, + app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + images_json_str = images_json_dumps(manual_images) + image_filename = primary_journal_image(manual_images) + has_manual_uploads = bool(manual_images) mood_issues = ",".join(request.form.getlist("mood_issues")) hold_duration = calc_duration_text(d.get("open_datetime", ""), d.get("close_datetime", "")) @@ -8590,7 +8532,10 @@ def add_journal(): except Exception: pass - want_exchange_chart = d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + want_exchange_chart = ( + not has_manual_uploads + and d.get("journal_exchange_chart", "").lower() in ("1", "true", "on", "yes") + ) chart_msg = None if want_exchange_chart and ORDER_CHART_ENABLED: coin = (d.get("coin") or "").strip().upper() @@ -8630,17 +8575,9 @@ def add_journal(): if saved: image_filename = saved chart_msg = f"已生成复盘K线图({'/'.join(journal_tfs)} 各{journal_limit}根):/static/images/{saved}" - if uploaded_tmp: - try: - old_path = os.path.join(app.config["UPLOAD_FOLDER"], uploaded_tmp) - if os.path.exists(old_path): - os.remove(old_path) - except Exception: - pass else: chart_msg = "已勾选自动生成K线图,但生成失败(返回空)。请检查 Pillow 是否安装、OKX 网络/代理是否正常。" except Exception as e: - image_filename = uploaded_tmp chart_msg = f"自动生成K线图失败:{str(e)}" conn = get_db() @@ -8649,8 +8586,8 @@ def add_journal(): (id, open_datetime, close_datetime, hold_duration, coin, tf, pnl, entry_reason, exit_reason, expect_rr, real_rr, early_exit, early_exit_reason, early_exit_trigger, early_exit_note, mood_score, mood_ai_score, mood_ai_comment, mood_issues, post_breakeven_stare, - new_trade_while_occupied, note, image) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + new_trade_while_occupied, note, image, images_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( entry_id, normalize_bj_datetime_storage(d.get("open_datetime")), @@ -8661,7 +8598,8 @@ def add_journal(): d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note, None, None, None, mood_issues, - d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename + d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename, + images_json_str, ) ) from lib.trade.account_risk_lib import on_journal_saved @@ -8697,41 +8635,27 @@ def api_journals(): conn.close() result = [] for r in rows: - item = row_to_dict(r) + item = enrich_journal_api_item(row_to_dict(r)) item["mood_issues"] = [x for x in (item.get("mood_issues") or "").split(",") if x] result.append(item) return jsonify(result) -@app.route("/api/journal_prefill", methods=["POST"]) -@login_required -def api_journal_prefill(): - file = request.files.get("screenshot") - if not file or not file.filename: - return jsonify({"ok": False, "msg": "请先选择截图文件"}), 400 - try: - raw = file.read() - if not raw: - return jsonify({"ok": False, "msg": "截图为空"}), 400 - image_b64 = base64.b64encode(raw).decode("utf-8") - except Exception as e: - return jsonify({"ok": False, "msg": f"读取截图失败:{str(e)}"}), 400 - - parsed = ai_extract_journal_from_image(image_b64) - if parsed is None: - return jsonify({"ok": False, "msg": "AI 识别失败,请稍后重试"}), 500 - return jsonify({"ok": True, "data": parsed}) - - @app.route("/delete_journal/", methods=["POST"]) @login_required def delete_journal(jid): conn = get_db() - row = conn.execute("SELECT image FROM journal_entries WHERE id=?", (jid,)).fetchone() - if row and row["image"]: - img_path = os.path.join(app.config["UPLOAD_FOLDER"], row["image"]) - if os.path.exists(img_path): - os.remove(img_path) + row = conn.execute( + "SELECT image, images_json FROM journal_entries WHERE id=?", + (jid,), + ).fetchone() + if row: + for img_path in journal_image_paths(row, app.config["UPLOAD_FOLDER"]): + try: + if os.path.exists(img_path): + os.remove(img_path) + except Exception: + pass conn.execute("DELETE FROM journal_entries WHERE id=?", (jid,)) conn.commit() conn.close() diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index d53c235..7549ca2 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -698,11 +698,12 @@ - + {% from 'journal_upload_slots.html' import journal_upload_slots %} + {{ journal_upload_slots() }}
@@ -730,9 +731,6 @@
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
- -
@@ -837,11 +835,13 @@
+ - + + @@ -1426,61 +1426,6 @@ function fillJournalFromTrade(t){ alert("已填入下方复盘表单,请手动补充主观原因。"); } -function prefillJournalByImage(){ - const fileInput = document.getElementById("journal-screenshot"); - if(!fileInput || !fileInput.files || !fileInput.files.length){ - alert("请先选择截图"); - return; - } - const fd = new FormData(); - fd.append("screenshot", fileInput.files[0]); - fetch("/api/journal_prefill", { method: "POST", body: fd }) - .then(r=>r.json()) - .then(res=>{ - if(!res.ok){ - alert(res.msg || "AI识别失败"); - return; - } - const d = res.data || {}; - setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime)); - setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime)); - setJournalField("coin", d.coin || ""); - setJournalField("tf", d.tf || ""); - setJournalField("pnl", d.pnl || ""); - setJournalField("expect_rr", d.expect_rr || ""); - setJournalField("real_rr", d.real_rr || ""); - let entryReason = String(d.entry_reason || "").trim(); - let customEr = ""; - if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){ - // keep - } else if(entryReason){ - customEr = entryReason; - entryReason = JOURNAL_ENTRY_REASON_OTHER; - } else { - entryReason = ""; - } - setJournalField("entry_reason", entryReason); - setJournalField("entry_reason_custom", customEr); - syncJournalEntryReasonOtherUi(); - const trig = (d.early_exit_trigger || "").trim(); - let noteEx = (d.early_exit_note || "").trim(); - const legacy = (d.early_exit_reason || "").trim(); - if(!noteEx && legacy && !trig){ - const sp = splitLegacyEarlyExitReason(legacy); - setJournalField("early_exit_trigger", sp.trigger); - setJournalField("early_exit_note", sp.note); - } else { - setJournalField("early_exit_trigger", trig); - setJournalField("early_exit_note", noteEx); - } - setJournalField("note", d.note || ""); - if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired(); - recomputeJournalRealRr(); - alert("已完成预填,请手动检查并补充原因"); - }) - .catch(()=>alert("AI识别请求失败")); -} - function recomputeJournalRealRr(){ const form = document.getElementById("journal-form"); if(!form) return; diff --git a/lib/ai/ai_review_lib.py b/lib/ai/ai_review_lib.py index 037bf07..83a7af2 100644 --- a/lib/ai/ai_review_lib.py +++ b/lib/ai/ai_review_lib.py @@ -12,6 +12,7 @@ from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_TF2, normalize_chart_timeframe, ) +from lib.instance.journal_images_lib import journal_image_paths def _journal_nz(v: Any, default: str = "无") -> str: @@ -92,32 +93,31 @@ def collect_images_for_ai_review( ) -> List[str]: """ 收集传给视觉模型的本地图片路径。 - - 优先 journal_entries.image 已存附图; + - 优先 journal_entries.images_json / image 已存附图(含多周期手动上传); - 若无附图且提供 build_chart_if_missing,则临时生成 K 线图。 """ paths: List[str] = [] seen = set() upload_folder = os.path.abspath(upload_folder or "") for row in rows or []: - candidate = None - try: - keys = row.keys() if hasattr(row, "keys") else [] - except Exception: - keys = [] - img = row["image"] if "image" in keys else None - if img: - candidate = os.path.join(upload_folder, str(img).strip()) - elif build_chart_if_missing: + row_paths = journal_image_paths(row, upload_folder) + if row_paths: + for candidate in row_paths: + if candidate not in seen: + seen.add(candidate) + paths.append(candidate) + continue + if build_chart_if_missing: try: candidate = build_chart_if_missing(row) except Exception: candidate = None - if not candidate: - continue - candidate = os.path.abspath(candidate) - if os.path.isfile(candidate) and candidate not in seen: - seen.add(candidate) - paths.append(candidate) + if not candidate: + continue + candidate = os.path.abspath(candidate) + if os.path.isfile(candidate) and candidate not in seen: + seen.add(candidate) + paths.append(candidate) return paths diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js index 3a748d1..2cf00ac 100644 --- a/lib/common/static/instance_embed.js +++ b/lib/common/static/instance_embed.js @@ -89,6 +89,10 @@ const root = document.getElementById("embed-page-root") || document; global.SymbolLivePrice.init(root); } + if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") { + const root = document.getElementById("embed-page-root") || document; + global.JournalUploadSlots.init(root); + } } function injectFragment(html) { diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 3b10dcc..bb21314 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -1659,3 +1659,108 @@ html[data-theme="light"] .symbol-live-price--ok { border-color: #9ed4b8; } +/* ── 复盘:四周期截图上传 / 详情四宫格 ── */ +.journal-upload-slots { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.journal-upload-slot { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border: 1px dashed rgba(130, 145, 190, 0.45); + border-radius: 8px; + background: rgba(12, 16, 32, 0.35); + font-size: 0.82rem; +} + +.journal-upload-slot-label { + color: #9aa3c7; + font-weight: 600; + letter-spacing: 0.02em; +} + +.journal-upload-slot-input { + font-size: 0.78rem; + max-width: 100%; +} + +.journal-upload-slot-preview { + min-height: 72px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.journal-upload-slot-preview--filled { + min-height: 96px; +} + +.journal-upload-slot-thumb { + width: 100%; + max-height: 140px; + object-fit: contain; + cursor: zoom-in; + border-radius: 4px; +} + +.journal-upload-hint { + margin-top: 6px; + font-size: 0.72rem; + color: #8892b0; +} + +.journal-detail-images { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + padding: 10px 14px 14px; + border-top: 1px solid rgba(130, 145, 190, 0.25); +} + +.journal-detail-img-cell { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.journal-detail-img-label { + font-size: 0.75rem; + color: #9aa3c7; + font-weight: 600; +} + +.journal-detail-img-thumb { + width: 100%; + max-height: 220px; + object-fit: contain; + background: rgba(0, 0, 0, 0.25); + border-radius: 6px; + cursor: zoom-in; +} + +html[data-theme="light"] .journal-upload-slot { + background: #f6f8fb; + border-color: #c5d0de; +} + +html[data-theme="light"] .journal-upload-slot-preview { + background: #eef2f7; +} + +html[data-theme="light"] .journal-detail-images { + border-top-color: #d0dae4; +} + +html[data-theme="light"] .journal-detail-img-thumb { + background: #eef2f7; +} + diff --git a/lib/common/static/instance_ui.js b/lib/common/static/instance_ui.js index 7e134fb..587af56 100644 --- a/lib/common/static/instance_ui.js +++ b/lib/common/static/instance_ui.js @@ -50,6 +50,76 @@ return lines.join("
"); } + function resolveJournalImages(o) { + if (Array.isArray(o.images) && o.images.length) return o.images; + if (o.image) return [{ tf: "", file: o.image }]; + return []; + } + + function setJournalDetailImages(o) { + const grid = document.getElementById("detailImages"); + const legacyImg = document.getElementById("detailImage"); + const images = resolveJournalImages(o || {}); + + if (grid) { + if (!images.length) { + grid.innerHTML = ""; + grid.style.display = "none"; + } else { + grid.innerHTML = images + .map(function (img) { + const tf = String(img.tf || "").trim(); + const file = String(img.file || "").trim(); + if (!file) return ""; + const label = tf ? escapeHtml(tf) : "截图"; + const src = "/static/images/" + encodeURIComponent(file).replace(/%2F/g, "/"); + return ( + '
' + + '' + + label + + "" + + '' +
+              label +
+              '' + + "
" + ); + }) + .join(""); + grid.style.display = "grid"; + } + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + return; + } + + if (legacyImg) { + if (images.length === 1) { + legacyImg.src = "/static/images/" + images[0].file; + legacyImg.style.display = "block"; + } else { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + } + + function clearJournalDetailImages() { + const grid = document.getElementById("detailImages"); + if (grid) { + grid.innerHTML = ""; + grid.style.display = "none"; + } + const legacyImg = document.getElementById("detailImage"); + if (legacyImg) { + legacyImg.src = ""; + legacyImg.style.display = "none"; + } + } + function setJournalDetailBody(o, formatExitLine) { const body = document.getElementById("detailBody"); if (!body) return; @@ -67,16 +137,7 @@ } setJournalDetailBody(o, formatExitLine); clearDetailActions(); - const imgEl = document.getElementById("detailImage"); - if (imgEl) { - if (o.image) { - imgEl.src = `/static/images/${o.image}`; - imgEl.style.display = "block"; - } else { - imgEl.src = ""; - imgEl.style.display = "none"; - } - } + setJournalDetailImages(o); if (typeof setDetailModalFullscreen === "function") { setDetailModalFullscreen(false); } @@ -265,5 +326,7 @@ buildTradeRecordDetailHtml: buildTradeRecordDetailHtml, openTradeRecordDetailModal: openTradeRecordDetailModal, clearDetailActions: clearDetailActions, + clearJournalDetailImages: clearJournalDetailImages, + setJournalDetailImages: setJournalDetailImages, }; })(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/journal_upload_slots.js b/lib/common/static/journal_upload_slots.js new file mode 100644 index 0000000..a44179b --- /dev/null +++ b/lib/common/static/journal_upload_slots.js @@ -0,0 +1,69 @@ +/** + * 复盘表单:四周期截图槽位本地预览。 + */ +(function (global) { + "use strict"; + + const previewUrls = new WeakMap(); + + function clearPreview(cell) { + if (!cell) return; + const prev = previewUrls.get(cell); + if (prev) { + URL.revokeObjectURL(prev); + previewUrls.delete(cell); + } + cell.innerHTML = ""; + cell.classList.remove("journal-upload-slot-preview--filled"); + } + + function renderPreview(cell, file, tf) { + clearPreview(cell); + if (!file || !cell) return; + const url = URL.createObjectURL(file); + previewUrls.set(cell, url); + const img = document.createElement("img"); + img.src = url; + img.alt = tf + " 预览"; + img.className = "journal-upload-slot-thumb"; + img.addEventListener("click", function (e) { + e.preventDefault(); + if (typeof global.showImage === "function") { + global.showImage(url); + } + }); + cell.appendChild(img); + cell.classList.add("journal-upload-slot-preview--filled"); + } + + function bindInput(input) { + if (!input || input.dataset.journalSlotBound === "1") return; + input.dataset.journalSlotBound = "1"; + const tf = input.getAttribute("data-tf") || ""; + const cell = input.closest(".journal-upload-slot"); + const preview = cell ? cell.querySelector(".journal-upload-slot-preview") : null; + input.addEventListener("change", function () { + const file = input.files && input.files[0]; + if (!file) { + clearPreview(preview); + return; + } + renderPreview(preview, file, tf); + }); + } + + function init(root) { + const scope = root || document; + scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput); + } + + global.JournalUploadSlots = { init: init }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", function () { + init(document); + }); + } else { + init(document); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/hub/hub_bridge.py b/lib/hub/hub_bridge.py index bf5e7b9..7a88955 100644 --- a/lib/hub/hub_bridge.py +++ b/lib/hub/hub_bridge.py @@ -64,6 +64,7 @@ def install_instance_theme_static(app) -> None: "time_close_ui.js": "application/javascript; charset=utf-8", "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", "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_images_lib.py b/lib/instance/journal_images_lib.py new file mode 100644 index 0000000..67d20c4 --- /dev/null +++ b/lib/instance/journal_images_lib.py @@ -0,0 +1,119 @@ +"""复盘记录:多周期截图上传、存储与读取(三所共用)。""" +from __future__ import annotations + +import json +import os +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence + +JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h") +JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}) + + +def journal_upload_field_name(tf: str) -> str: + return f"screenshot_{tf}" + + +def _safe_ext(filename: str) -> str: + ext = os.path.splitext(str(filename or ""))[1].lower() + return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png" + + +def save_journal_slot_uploads( + files, + entry_id: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> List[Dict[str, str]]: + """保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...]。""" + saved: List[Dict[str, str]] = [] + if not entry_id or not upload_folder: + return saved + os.makedirs(upload_folder, exist_ok=True) + for tf in JOURNAL_UPLOAD_TFS: + f = files.get(journal_upload_field_name(tf)) if files else None + if not f or not getattr(f, "filename", None): + continue + ext = _safe_ext(f.filename) + fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}") + if not fname: + continue + path = os.path.join(upload_folder, fname) + f.save(path) + saved.append({"tf": tf, "file": fname}) + return saved + + +def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]: + if not items: + return None + return json.dumps(list(items), ensure_ascii=False, separators=(",", ":")) + + +def parse_images_json(raw: Any) -> List[Dict[str, str]]: + if not raw: + return [] + if isinstance(raw, list): + data = raw + else: + try: + data = json.loads(str(raw)) + except (TypeError, ValueError, json.JSONDecodeError): + return [] + if not isinstance(data, list): + return [] + out: List[Dict[str, str]] = [] + for item in data: + if not isinstance(item, dict): + continue + tf = str(item.get("tf") or "").strip() + file = str(item.get("file") or "").strip() + if file: + out.append({"tf": tf, "file": file}) + return out + + +def primary_journal_image( + manual_images: Sequence[Mapping[str, str]], + *, + fallback: Optional[str] = None, +) -> Optional[str]: + if manual_images: + return str(manual_images[0].get("file") or "").strip() or None + return fallback + + +def enrich_journal_api_item(item: Dict[str, Any]) -> Dict[str, Any]: + """API 输出:解析 images_json,兼容旧单图 image 字段。""" + images = parse_images_json(item.get("images_json")) + if not images and item.get("image"): + images = [{"tf": "", "file": str(item["image"]).strip()}] + item["images"] = images + return item + + +def journal_image_paths(row: Any, upload_folder: str) -> List[str]: + """删除 / AI 附图:收集本条复盘所有本地图片路径(去重)。""" + upload_folder = os.path.abspath(upload_folder or "") + paths: List[str] = [] + seen = set() + + def _add(name: Optional[str]) -> None: + if not name: + return + p = os.path.abspath(os.path.join(upload_folder, str(name).strip())) + if os.path.isfile(p) and p not in seen: + seen.add(p) + paths.append(p) + + try: + keys = row.keys() if hasattr(row, "keys") else () + except Exception: + keys = () + + if "images_json" in keys and row["images_json"]: + for img in parse_images_json(row["images_json"]): + _add(img.get("file")) + if "image" in keys: + _add(row["image"]) + return paths diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index 7e3c7b6..ad94a6f 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -583,61 +583,6 @@ function fillJournalFromTrade(t){ alert("已填入下方复盘表单,请手动补充主观原因。"); } -function prefillJournalByImage(){ - const fileInput = document.getElementById("journal-screenshot"); - if(!fileInput || !fileInput.files || !fileInput.files.length){ - alert("请先选择截图"); - return; - } - const fd = new FormData(); - fd.append("screenshot", fileInput.files[0]); - fetch("/api/journal_prefill", { method: "POST", body: fd }) - .then(r=>r.json()) - .then(res=>{ - if(!res.ok){ - alert(res.msg || "AI识别失败"); - return; - } - const d = res.data || {}; - setJournalField("open_datetime", normalizeDatetimeLocal(d.open_datetime)); - setJournalField("close_datetime", normalizeDatetimeLocal(d.close_datetime)); - setJournalField("coin", d.coin || ""); - setJournalField("tf", d.tf || ""); - setJournalField("pnl", d.pnl || ""); - setJournalField("expect_rr", d.expect_rr || ""); - setJournalField("real_rr", d.real_rr || ""); - let entryReason = String(d.entry_reason || "").trim(); - let customEr = ""; - if(JOURNAL_ENTRY_REASON_OPTIONS && JOURNAL_ENTRY_REASON_OPTIONS.includes(entryReason)){ - // keep - } else if(entryReason){ - customEr = entryReason; - entryReason = JOURNAL_ENTRY_REASON_OTHER; - } else { - entryReason = ""; - } - setJournalField("entry_reason", entryReason); - setJournalField("entry_reason_custom", customEr); - syncJournalEntryReasonOtherUi(); - const trig = (d.early_exit_trigger || "").trim(); - let noteEx = (d.early_exit_note || "").trim(); - const legacy = (d.early_exit_reason || "").trim(); - if(!noteEx && legacy && !trig){ - const sp = splitLegacyEarlyExitReason(legacy); - setJournalField("early_exit_trigger", sp.trigger); - setJournalField("early_exit_note", sp.note); - } else { - setJournalField("early_exit_trigger", trig); - setJournalField("early_exit_note", noteEx); - } - setJournalField("note", d.note || ""); - if(typeof syncEarlyExitNoteRequired === "function") syncEarlyExitNoteRequired(); - recomputeJournalRealRr(); - alert("已完成预填,请手动检查并补充原因"); - }) - .catch(()=>alert("AI识别请求失败")); -} - function recomputeJournalRealRr(){ const form = document.getElementById("journal-form"); if(!form) return; diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index c886bca..54209e9 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -345,11 +345,12 @@ - + {% from 'journal_upload_slots.html' import journal_upload_slots %} + {{ journal_upload_slots() }}
@@ -377,9 +378,6 @@
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
- -
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 52a7089..81f50fb 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -111,11 +111,13 @@
+ - + + diff --git a/lib/strategy/templates/journal_upload_slots.html b/lib/strategy/templates/journal_upload_slots.html new file mode 100644 index 0000000..1a98687 --- /dev/null +++ b/lib/strategy/templates/journal_upload_slots.html @@ -0,0 +1,19 @@ +{# 复盘四周期截图槽位(须加载 journal_upload_slots.js) #} +{% macro journal_upload_slots() -%} +
+ {% for tf in ['5m', '15m', '1h', '4h'] %} + + {% endfor %} +
+

可只传部分周期;保存后按 5m / 15m / 1h / 4h 命名,详情页四宫格查看

+{%- endmacro %} diff --git a/tests/test_journal_images_lib.py b/tests/test_journal_images_lib.py new file mode 100644 index 0000000..0950bf7 --- /dev/null +++ b/tests/test_journal_images_lib.py @@ -0,0 +1,91 @@ +"""journal_images_lib 单元测试。""" +import json +import os +import tempfile +import unittest +from io import BytesIO + +from lib.instance.journal_images_lib import ( + JOURNAL_UPLOAD_TFS, + enrich_journal_api_item, + images_json_dumps, + journal_image_paths, + journal_upload_field_name, + parse_images_json, + primary_journal_image, + save_journal_slot_uploads, +) + + +class _FakeFile: + def __init__(self, filename: str, data: bytes): + self.filename = filename + self._data = data + + def save(self, path: str) -> None: + with open(path, "wb") as f: + f.write(self._data) + + +class _FakeFiles: + def __init__(self, mapping): + self._mapping = mapping + + def get(self, key): + return self._mapping.get(key) + + +class JournalImagesLibTest(unittest.TestCase): + def test_field_names(self): + self.assertEqual(journal_upload_field_name("5m"), "screenshot_5m") + + def test_save_slot_uploads_partial(self): + with tempfile.TemporaryDirectory() as tmp: + files = _FakeFiles( + { + "screenshot_5m": _FakeFile("a.png", b"png5"), + "screenshot_1h": _FakeFile("b.jpg", b"jpg1"), + } + ) + saved = save_journal_slot_uploads( + files, + "abc123", + tmp, + secure_filename_fn=lambda x: x, + ) + self.assertEqual(len(saved), 2) + self.assertEqual(saved[0]["tf"], "5m") + self.assertTrue(os.path.isfile(os.path.join(tmp, saved[0]["file"]))) + self.assertEqual(saved[1]["tf"], "1h") + + def test_parse_and_enrich(self): + raw = images_json_dumps([{"tf": "5m", "file": "journal_x_5m.png"}]) + item = enrich_journal_api_item({"images_json": raw, "image": "legacy.png"}) + self.assertEqual(len(item["images"]), 1) + self.assertEqual(item["images"][0]["tf"], "5m") + + legacy = enrich_journal_api_item({"image": "only.png"}) + self.assertEqual(legacy["images"][0]["file"], "only.png") + + def test_journal_image_paths_dedupe(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "same.png") + with open(path, "wb") as f: + f.write(b"x") + row = { + "image": "same.png", + "images_json": json.dumps([{"tf": "5m", "file": "same.png"}]), + } + paths = journal_image_paths(row, tmp) + self.assertEqual(len(paths), 1) + + def test_primary_journal_image(self): + self.assertEqual( + primary_journal_image([{"tf": "5m", "file": "a.png"}]), + "a.png", + ) + self.assertIsNone(primary_journal_image([])) + + +if __name__ == "__main__": + unittest.main()