-
-
+
+
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 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
-
-
+
+
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 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
-
-
-
+
+
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()