ui: 复盘表单紧凑布局、即时上传截图并移除占用时新开仓
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
@@ -296,39 +296,11 @@
|
||||
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
|
||||
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
|
||||
<input type="hidden" name="direction_hint" id="direction-hint">
|
||||
<div class="form-grid">
|
||||
<input type="datetime-local" name="open_datetime" required>
|
||||
<input type="datetime-local" name="close_datetime" required>
|
||||
<input name="coin" placeholder="BTC" required>
|
||||
<input name="tf" placeholder="5m" required>
|
||||
<input name="pnl" placeholder="盈亏(U)" required>
|
||||
<select name="entry_reason" id="journal-entry-reason" required title="固定五种或选其他手写">
|
||||
<option value="">开仓类型(必选)</option>
|
||||
{% for er in entry_reason_options %}
|
||||
<option value="{{ er }}">{{ er }}</option>
|
||||
{% endfor %}
|
||||
<option value="{{ entry_reason_other_value }}">其他(自定义,见下方说明框)</option>
|
||||
</select>
|
||||
<input type="text" name="entry_reason_custom" id="journal-entry-reason-custom" maxlength="2000" placeholder="选「其他」时在此填写开仓类型说明" autocomplete="off" style="display:none">
|
||||
<input name="expect_rr" placeholder="预期RR">
|
||||
<input name="real_rr" placeholder="实际RR">
|
||||
<select name="early_exit_trigger" required title="平仓如何触发">
|
||||
<option value="">离场触发(必选)</option>
|
||||
<option value="止盈">止盈</option>
|
||||
<option value="保本止盈">保本止盈</option>
|
||||
<option value="移动止盈">移动止盈</option>
|
||||
<option value="时间平仓">时间平仓</option>
|
||||
<option value="手动平仓">手动平仓</option>
|
||||
<option value="止损">止损</option>
|
||||
<option value="其他">其他</option>
|
||||
</select>
|
||||
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
|
||||
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
|
||||
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
|
||||
</div>
|
||||
{% 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() }}
|
||||
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
|
||||
<div class="form-row journal-chart-options" style="flex-wrap:wrap;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
|
||||
<input type="checkbox" name="journal_exchange_chart" value="true">
|
||||
保存时自动生成 K 线图并作为截图
|
||||
@@ -357,8 +329,8 @@
|
||||
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
||||
<div class="mood-grid" style="margin-top:8px">
|
||||
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:2px;margin-bottom:0">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
|
||||
<div class="mood-grid">
|
||||
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
|
||||
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
|
||||
<label><input type="checkbox" name="mood_issues" value="盈利飘了">盈利飘了</label>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||
<link rel="stylesheet" href="/static/instance_page.css?v=2">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=50">
|
||||
<link rel="stylesheet" href="/static/instance_theme.css?v=52">
|
||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||
<meta name="theme-color" content="#0b0d14">
|
||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||
@@ -115,8 +115,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/instance_ui.js?v=5"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=1"></script>
|
||||
<script src="/static/instance_ui.js?v=6"></script>
|
||||
<script src="/static/journal_upload_slots.js?v=3"></script>
|
||||
<script src="/static/instance_records_mobile.js?v=2"></script>
|
||||
<script src="/static/time_close_ui.js?v=2"></script>
|
||||
<script src="/static/ai_review_render.js?v=2"></script>
|
||||
|
||||
Reference in New Issue
Block a user