diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 356a271..45912ed 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -76,12 +76,14 @@ from lib.strategy.strategy_trade_labels import ( trend_plan_id_from_monitor_row, ) from lib.instance.journal_images_lib import ( + collect_journal_slot_images, enrich_journal_api_item, images_json_dumps, journal_image_paths, + normalize_journal_draft_id, primary_journal_image, - save_journal_slot_uploads, ) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -9088,8 +9090,9 @@ 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) - entry_id = uuid.uuid4().hex - manual_images = save_journal_slot_uploads( + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, request.files, entry_id, app.config["UPLOAD_FOLDER"], @@ -9177,7 +9180,7 @@ 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"), None, d.get("note"), image_filename, images_json_str, ) ) @@ -9200,6 +9203,17 @@ def add_journal(): return _redirect_records() +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + @app.route("/api/journals") @login_required def api_journals(): diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index eaca758..d9e6b60 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -243,7 +243,7 @@ .stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4} - + -
- - - - - - - - - - - - - -
+ {% from 'journal_form_fields.html' import journal_form_fields %} + {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }} {% from 'journal_upload_slots.html' import journal_upload_slots %} {{ journal_upload_slots() }} -
+
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
+
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
+
@@ -820,8 +792,8 @@
- - + + diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 7ee517f..b8fe944 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -77,12 +77,14 @@ from lib.strategy.strategy_trade_labels import ( trend_plan_id_from_monitor_row, ) from lib.instance.journal_images_lib import ( + collect_journal_slot_images, enrich_journal_api_item, images_json_dumps, journal_image_paths, + normalize_journal_draft_id, primary_journal_image, - save_journal_slot_uploads, ) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -8910,8 +8912,9 @@ 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) - entry_id = uuid.uuid4().hex - manual_images = save_journal_slot_uploads( + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, request.files, entry_id, app.config["UPLOAD_FOLDER"], @@ -8999,7 +9002,7 @@ 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"), None, d.get("note"), image_filename, images_json_str, ) ) @@ -9022,6 +9025,17 @@ def add_journal(): return _redirect_records() +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + @app.route("/api/journals") @login_required def api_journals(): diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index ee0b617..27a8168 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -243,7 +243,7 @@ .stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4} - + -
- - - - - - - - - - - - - -
+ {% from 'journal_form_fields.html' import journal_form_fields %} + {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }} {% from 'journal_upload_slots.html' import journal_upload_slots %} {{ journal_upload_slots() }} -
+
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
+
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
+
@@ -787,8 +759,8 @@
- - + + diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 817fa71..ebc88b9 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -77,12 +77,14 @@ from lib.strategy.strategy_trade_labels import ( ) from lib.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders from lib.instance.journal_images_lib import ( + collect_journal_slot_images, enrich_journal_api_item, images_json_dumps, journal_image_paths, + normalize_journal_draft_id, primary_journal_image, - save_journal_slot_uploads, ) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot from lib.instance.journal_chart_lib import ( JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_TF1, @@ -8459,8 +8461,9 @@ 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) - entry_id = uuid.uuid4().hex - manual_images = save_journal_slot_uploads( + entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex + manual_images = collect_journal_slot_images( + d, request.files, entry_id, app.config["UPLOAD_FOLDER"], @@ -8548,7 +8551,7 @@ 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"), None, d.get("note"), image_filename, images_json_str, ) ) @@ -8571,6 +8574,17 @@ def add_journal(): return _redirect_records() +@app.route("/api/journal_upload_slot", methods=["POST"]) +@login_required +def api_journal_upload_slot(): + payload, code = handle_journal_upload_slot( + request, + upload_folder=app.config["UPLOAD_FOLDER"], + secure_filename_fn=secure_filename, + ) + return jsonify(payload), code + + @app.route("/api/journals") @login_required def api_journals(): diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index f0f42dd..f96e1b1 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -243,7 +243,7 @@ .stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4} - + -
- - - - - - - - - - - - - -
+ {% from 'journal_form_fields.html' import journal_form_fields %} + {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }} {% from 'journal_upload_slots.html' import journal_upload_slots %} {{ journal_upload_slots() }} -
+
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
+
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
+
@@ -816,8 +788,8 @@
- - + + diff --git a/lib/ai/ai_review_lib.py b/lib/ai/ai_review_lib.py index 83a7af2..e385e38 100644 --- a/lib/ai/ai_review_lib.py +++ b/lib/ai/ai_review_lib.py @@ -78,8 +78,6 @@ def journal_row_lines_for_ai( lines.append(f" {mood_bits}") if _journal_nz(_row_get(row, "post_breakeven_stare")) != "无": lines.append(f" 保本后盯盘:{_journal_nz(_row_get(row, 'post_breakeven_stare'))}") - if _journal_nz(_row_get(row, "new_trade_while_occupied")) != "无": - lines.append(f" 占用时新开仓:{_journal_nz(_row_get(row, 'new_trade_while_occupied'))}") if _journal_nz(_row_get(row, "note")) != "无": lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}") return "\n".join(lines) + "\n" diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index bb21314..df99ce7 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -1660,63 +1660,118 @@ html[data-theme="light"] .symbol-live-price--ok { } /* ── 复盘:四周期截图上传 / 详情四宫格 ── */ -.journal-upload-slots { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin-top: 10px; +.journal-card .journal-form-row1 { + grid-template-columns: repeat(7, minmax(0, 1fr)); + margin-bottom: 6px; } -.journal-upload-slot { +.journal-card .journal-form-row2 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.journal-card .journal-form-row2 select[name="entry_reason"] { + font-size: 0.8rem; + line-height: 1.35; +} + +.journal-upload-slots { + display: flex; + flex-wrap: nowrap; + gap: 10px; + align-items: flex-start; + margin-top: 8px; +} + +.journal-upload-row { 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; + align-items: stretch; + gap: 4px; + flex: 1 1 0; + min-width: 0; } .journal-upload-slot-label { color: #9aa3c7; font-weight: 600; + font-size: 0.78rem; 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; + min-width: 0; + font-size: 0.72rem; + padding: 3px 4px; + line-height: 1.2; +} + +.journal-upload-status { + font-size: 0.68rem; + color: #8892b0; + min-height: 1.1em; + line-height: 1.25; + word-break: break-all; +} + +.journal-upload-status--pending { + color: #c9b458; +} + +.journal-upload-status--ok { + color: #6bc98a; +} + +.journal-upload-status--err { + color: #ff7b7b; } .journal-upload-hint { - margin-top: 6px; + margin-top: 4px; + margin-bottom: 0; font-size: 0.72rem; color: #8892b0; } +.journal-card .journal-upload-slots { + margin-bottom: 2px; +} + +.journal-card .form-row.journal-chart-options { + margin-top: 6px; + margin-bottom: 6px; + gap: 6px; +} + +.journal-card .mood-grid { + margin-top: 6px; + gap: 8px; +} + +@media (max-width: 960px) { + .journal-card .journal-form-row1 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .journal-card .journal-form-row2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .journal-upload-slots { + flex-wrap: wrap; + } + + .journal-upload-row { + flex: 1 1 calc(50% - 8px); + } +} + +@media (max-width: 560px) { + .journal-upload-row { + flex: 1 1 100%; + } +} + .journal-detail-images { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1747,15 +1802,6 @@ html[data-theme="light"] .symbol-live-price--ok { 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; } diff --git a/lib/common/static/instance_ui.js b/lib/common/static/instance_ui.js index 587af56..d7fb1da 100644 --- a/lib/common/static/instance_ui.js +++ b/lib/common/static/instance_ui.js @@ -43,7 +43,6 @@ `预期RR:${escapeHtml(o.expect_rr || "-")}`, `实际RR:${escapeHtml(o.real_rr || "-")}`, `保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`, - `占用时新开仓:${escapeHtml(o.new_trade_while_occupied || "-")}`, `心态标签:${escapeHtml(moodTags)}`, `备注:${escapeHtml(o.note || "无")}`, ]; diff --git a/lib/common/static/journal_upload_slots.js b/lib/common/static/journal_upload_slots.js index a44179b..4d6a08b 100644 --- a/lib/common/static/journal_upload_slots.js +++ b/lib/common/static/journal_upload_slots.js @@ -1,63 +1,139 @@ /** - * 复盘表单:四周期截图槽位本地预览。 + * 复盘表单:四周期截图即时上传与状态展示。 */ (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); + function newDraftId() { + if (global.crypto && typeof global.crypto.randomUUID === "function") { + return global.crypto.randomUUID().replace(/-/g, ""); } - cell.innerHTML = ""; - cell.classList.remove("journal-upload-slot-preview--filled"); + var s = ""; + for (var i = 0; i < 32; i++) { + s += Math.floor(Math.random() * 16).toString(16); + } + return s; } - 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 ensureDraftId(root) { + var scope = root || document; + var el = scope.querySelector("#journal-draft-id"); + if (!el) return ""; + if (!el.value) { + el.value = newDraftId(); + } + return el.value; + } + + function rowParts(input) { + var row = input.closest(".journal-upload-row"); + if (!row) return {}; + return { + row: row, + status: row.querySelector(".journal-upload-status"), + hidden: row.querySelector(".journal-upload-hidden-file"), + }; + } + + function setStatus(statusEl, text, kind) { + if (!statusEl) return; + statusEl.textContent = text || ""; + statusEl.classList.remove( + "journal-upload-status--pending", + "journal-upload-status--ok", + "journal-upload-status--err" + ); + if (kind) { + statusEl.classList.add("journal-upload-status--" + kind); + } + } + + function uploadSlotFile(input, file) { + var parts = rowParts(input); + var draftId = ensureDraftId(input.form || document); + if (!draftId || !file) { + setStatus(parts.status, "上传失败", "err"); + return; + } + + setStatus(parts.status, "上传中…", "pending"); + if (parts.hidden) { + parts.hidden.value = ""; + } + + var fd = new FormData(); + fd.append("journal_draft_id", draftId); + fd.append("tf", input.getAttribute("data-tf") || ""); + fd.append("file", file); + + fetch("/api/journal_upload_slot", { method: "POST", body: fd, credentials: "same-origin" }) + .then(function (res) { + return res.json().then(function (data) { + return { ok: res.ok, data: data }; + }); + }) + .then(function (result) { + if (!result.ok || !result.data || !result.data.ok) { + throw new Error( + (result.data && result.data.error) || "upload failed" + ); + } + var fname = String(result.data.file || "").trim(); + if (parts.hidden) { + parts.hidden.value = fname; + } + input.value = ""; + setStatus(parts.status, "上传成功 " + fname, "ok"); + }) + .catch(function () { + if (parts.hidden) { + parts.hidden.value = ""; + } + setStatus(parts.status, "上传失败", "err"); + }); } 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]; + var file = input.files && input.files[0]; if (!file) { - clearPreview(preview); + var parts = rowParts(input); + if (parts.hidden) { + parts.hidden.value = ""; + } + setStatus(parts.status, "", ""); return; } - renderPreview(preview, file, tf); + uploadSlotFile(input, file); + }); + } + + function resetSlots(root) { + var scope = root || document; + var draftEl = scope.querySelector("#journal-draft-id"); + if (draftEl) { + draftEl.value = newDraftId(); + } + scope.querySelectorAll(".journal-upload-hidden-file").forEach(function (el) { + el.value = ""; + }); + scope.querySelectorAll(".journal-upload-slot-input").forEach(function (el) { + el.value = ""; + }); + scope.querySelectorAll(".journal-upload-status").forEach(function (el) { + setStatus(el, "", ""); }); } function init(root) { - const scope = root || document; + var scope = root || document; + ensureDraftId(scope); scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput); } - global.JournalUploadSlots = { init: init }; + global.JournalUploadSlots = { init: init, reset: resetSlots }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { diff --git a/lib/instance/journal_images_lib.py b/lib/instance/journal_images_lib.py index 67d20c4..316fe5f 100644 --- a/lib/instance/journal_images_lib.py +++ b/lib/instance/journal_images_lib.py @@ -3,21 +3,119 @@ from __future__ import annotations import json import os +import re 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"}) +_JOURNAL_DRAFT_ID_RE = re.compile(r"^[a-f0-9]{32}$") +_JOURNAL_SLOT_FILE_RE = re.compile( + r"^journal_([a-f0-9]{32})_(5m|15m|1h|4h)\.(png|jpg|jpeg|webp|gif|bmp)$", + re.I, +) def journal_upload_field_name(tf: str) -> str: return f"screenshot_{tf}" +def uploaded_screenshot_field_name(tf: str) -> str: + return f"uploaded_screenshot_{tf}" + + +def normalize_journal_draft_id(raw: Any) -> Optional[str]: + s = str(raw or "").strip().lower() + if _JOURNAL_DRAFT_ID_RE.match(s): + return s + return None + + 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 build_journal_slot_filename( + entry_id: str, + tf: str, + ext: str, + *, + secure_filename_fn: Callable[[str], str], +) -> str: + ext = ext if ext.startswith(".") else f".{ext}" + ext = _safe_ext(f"x{ext}") + fname = secure_filename_fn(f"journal_{entry_id}_{tf}{ext}") + return fname or "" + + +def is_valid_preuploaded_journal_file(filename: str, entry_id: str, tf: str) -> bool: + fn = os.path.basename(str(filename or "").strip()) + if not fn or fn != str(filename or "").strip(): + return False + m = _JOURNAL_SLOT_FILE_RE.match(fn) + if not m: + return False + return m.group(1) == entry_id.lower() and m.group(2) == tf + + +def save_journal_slot_file( + file, + entry_id: str, + tf: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> Optional[Dict[str, str]]: + if tf not in JOURNAL_UPLOAD_TFS or not entry_id or not upload_folder: + return None + if not file or not getattr(file, "filename", None): + return None + ext = _safe_ext(file.filename) + fname = build_journal_slot_filename( + entry_id, tf, ext, secure_filename_fn=secure_filename_fn + ) + if not fname: + return None + os.makedirs(upload_folder, exist_ok=True) + path = os.path.join(upload_folder, fname) + file.save(path) + return {"tf": tf, "file": fname} + + +def collect_journal_slot_images( + form, + files, + entry_id: str, + upload_folder: str, + *, + secure_filename_fn: Callable[[str], str], +) -> List[Dict[str, str]]: + """优先使用即时上传 hidden 字段;否则回退到表单 multipart。""" + saved: List[Dict[str, str]] = [] + if not entry_id or not upload_folder: + return saved + for tf in JOURNAL_UPLOAD_TFS: + pre = "" + if form is not None: + pre = str(form.get(uploaded_screenshot_field_name(tf)) or "").strip() + if pre and is_valid_preuploaded_journal_file(pre, entry_id, tf): + path = os.path.join(upload_folder, os.path.basename(pre)) + if os.path.isfile(path): + saved.append({"tf": tf, "file": os.path.basename(pre)}) + continue + f = files.get(journal_upload_field_name(tf)) if files else None + item = save_journal_slot_file( + f, + entry_id, + tf, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + if item: + saved.append(item) + return saved + + def save_journal_slot_uploads( files, entry_id: str, @@ -26,22 +124,13 @@ def save_journal_slot_uploads( 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 + return collect_journal_slot_images( + None, + files, + entry_id, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]: diff --git a/lib/instance/journal_upload_api_lib.py b/lib/instance/journal_upload_api_lib.py new file mode 100644 index 0000000..3a2a5bc --- /dev/null +++ b/lib/instance/journal_upload_api_lib.py @@ -0,0 +1,43 @@ +"""复盘截图即时上传 API(三所共用)。""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Tuple + +from lib.instance.journal_images_lib import ( + JOURNAL_UPLOAD_TFS, + normalize_journal_draft_id, + save_journal_slot_file, +) + + +def handle_journal_upload_slot( + request: Any, + *, + upload_folder: str, + secure_filename_fn: Callable[[str], str], +) -> Tuple[Dict[str, Any], int]: + """POST multipart: journal_draft_id, tf, file → {ok, file}。""" + draft_id = normalize_journal_draft_id( + request.form.get("journal_draft_id") if request.form else None + ) + tf = str((request.form.get("tf") if request.form else None) or "").strip() + if not draft_id: + return {"ok": False, "error": "invalid draft_id"}, 400 + if tf not in JOURNAL_UPLOAD_TFS: + return {"ok": False, "error": "invalid tf"}, 400 + + f = request.files.get("file") if request.files else None + if not f or not getattr(f, "filename", None): + return {"ok": False, "error": "no file"}, 400 + + item = save_journal_slot_file( + f, + draft_id, + tf, + upload_folder, + secure_filename_fn=secure_filename_fn, + ) + if not item: + return {"ok": False, "error": "save failed"}, 500 + + return {"ok": True, "tf": tf, "file": item["file"]}, 200 diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 6aca57b..94d432b 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -296,39 +296,11 @@ -
- - - - - - - - - - - - - -
+ {% from 'journal_form_fields.html' import journal_form_fields %} + {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }} {% from 'journal_upload_slots.html' import journal_upload_slots %} {{ journal_upload_slots() }} -
+
-
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
+
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
+
diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 73fcae5..464f5c8 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -7,7 +7,7 @@ - + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 @@ -115,8 +115,8 @@
- - + + diff --git a/lib/strategy/templates/journal_form_fields.html b/lib/strategy/templates/journal_form_fields.html new file mode 100644 index 0000000..b408ca3 --- /dev/null +++ b/lib/strategy/templates/journal_form_fields.html @@ -0,0 +1,34 @@ +{# 复盘表单字段两行布局 #} +{% macro journal_form_fields(entry_reason_options, entry_reason_other_value) -%} +
+ + + + + + + +
+
+ + + + +
+ +{%- endmacro %} diff --git a/lib/strategy/templates/journal_upload_slots.html b/lib/strategy/templates/journal_upload_slots.html index 1a98687..df9d83f 100644 --- a/lib/strategy/templates/journal_upload_slots.html +++ b/lib/strategy/templates/journal_upload_slots.html @@ -1,19 +1,20 @@ {# 复盘四周期截图槽位(须加载 journal_upload_slots.js) #} {% macro journal_upload_slots() -%} +
{% for tf in ['5m', '15m', '1h', '4h'] %} -
-

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

+

可只传部分周期;选文件后即时上传,保存后详情页四宫格查看

{%- endmacro %} diff --git a/tests/test_journal_images_lib.py b/tests/test_journal_images_lib.py index 0950bf7..42d5643 100644 --- a/tests/test_journal_images_lib.py +++ b/tests/test_journal_images_lib.py @@ -1,4 +1,4 @@ -"""journal_images_lib 单元测试。""" +"""journal_images_lib / journal_upload_api_lib 单元测试。""" import json import os import tempfile @@ -7,14 +7,19 @@ from io import BytesIO from lib.instance.journal_images_lib import ( JOURNAL_UPLOAD_TFS, + collect_journal_slot_images, enrich_journal_api_item, images_json_dumps, + is_valid_preuploaded_journal_file, journal_image_paths, journal_upload_field_name, + normalize_journal_draft_id, parse_images_json, primary_journal_image, save_journal_slot_uploads, + uploaded_screenshot_field_name, ) +from lib.instance.journal_upload_api_lib import handle_journal_upload_slot class _FakeFile: @@ -35,9 +40,29 @@ class _FakeFiles: return self._mapping.get(key) +class _FakeForm: + def __init__(self, mapping): + self._mapping = mapping + + def get(self, key, default=None): + return self._mapping.get(key, default) + + +class _FakeRequest: + def __init__(self, form=None, files=None): + self.form = form + self.files = files + + class JournalImagesLibTest(unittest.TestCase): def test_field_names(self): self.assertEqual(journal_upload_field_name("5m"), "screenshot_5m") + self.assertEqual(uploaded_screenshot_field_name("5m"), "uploaded_screenshot_5m") + + def test_normalize_draft_id(self): + good = "a" * 32 + self.assertEqual(normalize_journal_draft_id(good), good) + self.assertIsNone(normalize_journal_draft_id("bad")) def test_save_slot_uploads_partial(self): with tempfile.TemporaryDirectory() as tmp: @@ -49,7 +74,7 @@ class JournalImagesLibTest(unittest.TestCase): ) saved = save_journal_slot_uploads( files, - "abc123", + "abc123" + "0" * 26, tmp, secure_filename_fn=lambda x: x, ) @@ -58,6 +83,29 @@ class JournalImagesLibTest(unittest.TestCase): self.assertTrue(os.path.isfile(os.path.join(tmp, saved[0]["file"]))) self.assertEqual(saved[1]["tf"], "1h") + def test_collect_preuploaded(self): + entry_id = "abc123" + "0" * 26 + fname = f"journal_{entry_id}_5m.png" + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, fname), "wb") as f: + f.write(b"x") + form = _FakeForm({uploaded_screenshot_field_name("5m"): fname}) + saved = collect_journal_slot_images( + form, + _FakeFiles({}), + entry_id, + tmp, + secure_filename_fn=lambda x: x, + ) + self.assertEqual(saved, [{"tf": "5m", "file": fname}]) + + def test_is_valid_preuploaded_journal_file(self): + entry_id = "abc123" + "0" * 26 + fname = f"journal_{entry_id}_5m.png" + self.assertTrue(is_valid_preuploaded_journal_file(fname, entry_id, "5m")) + self.assertFalse(is_valid_preuploaded_journal_file("../evil.png", entry_id, "5m")) + self.assertFalse(is_valid_preuploaded_journal_file(fname, "b" * 32, "5m")) + 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"}) @@ -86,6 +134,23 @@ class JournalImagesLibTest(unittest.TestCase): ) self.assertIsNone(primary_journal_image([])) + def test_handle_journal_upload_slot(self): + entry_id = "abc123" + "0" * 26 + with tempfile.TemporaryDirectory() as tmp: + req = _FakeRequest( + form=_FakeForm({"journal_draft_id": entry_id, "tf": "5m"}), + files=_FakeFiles({"file": _FakeFile("local.png", b"data")}), + ) + payload, code = handle_journal_upload_slot( + req, + upload_folder=tmp, + secure_filename_fn=lambda x: x, + ) + self.assertEqual(code, 200) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["tf"], "5m") + self.assertTrue(os.path.isfile(os.path.join(tmp, payload["file"]))) + if __name__ == "__main__": unittest.main()