feat: 复盘四周期手动上传,移除 AI 识别预填

固定 5m/15m/1h/4h 四槽截图上传与详情四宫格展示;自动 K 线默认关闭且与手动上传互斥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-05 21:57:41 +08:00
parent eec57610dc
commit 88fe4bbe56
18 changed files with 644 additions and 621 deletions
+119
View File
@@ -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
@@ -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;
@@ -345,11 +345,12 @@
<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>
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
</div>
{% 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">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
<input type="checkbox" name="journal_exchange_chart" value="true">
保存时自动生成 K 线图并作为截图
</label>
<label style="font-size:.82rem;color:#9aa">周期1</label>
@@ -377,9 +378,6 @@
</select>
</div>
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
<div class="form-row" style="margin-top:8px">
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
</div>
<div class="mood-grid" style="margin-top:8px">
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
+3 -1
View File
@@ -111,11 +111,13 @@
</div>
</div>
<div class="panel-body" id="detailBody"></div>
<div id="detailImages" class="journal-detail-images" style="display:none"></div>
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
</div>
</div>
<script src="/static/instance_ui.js?v=4"></script>
<script src="/static/instance_ui.js?v=5"></script>
<script src="/static/journal_upload_slots.js?v=1"></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>