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
+41 -117
View File
@@ -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_noteearly_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/<jid>", 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()
+6 -61
View File
@@ -702,11 +702,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>
@@ -734,9 +735,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>
@@ -841,11 +839,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>
@@ -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;
+41 -117
View File
@@ -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_noteearly_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/<jid>", 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()
+6 -61
View File
@@ -669,11 +669,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>
@@ -701,9 +702,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>
@@ -808,11 +806,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>
@@ -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;
+41 -117
View File
@@ -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_noteearly_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/<jid>", 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()
+6 -61
View File
@@ -698,11 +698,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>
@@ -730,9 +731,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>
@@ -837,11 +835,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>
@@ -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;
+16 -16
View File
@@ -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
+4
View File
@@ -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) {
+105
View File
@@ -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;
}
+73 -10
View File
@@ -50,6 +50,76 @@
return lines.join("<br>");
}
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 (
'<div class="journal-detail-img-cell">' +
'<span class="journal-detail-img-label">' +
label +
"</span>" +
'<img class="journal-detail-img-thumb" src="' +
src +
'" alt="' +
label +
'" onclick="showImage(this.src)">' +
"</div>"
);
})
.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);
+69
View File
@@ -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);
+1
View File
@@ -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",
+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>
@@ -0,0 +1,19 @@
{# 复盘四周期截图槽位(须加载 journal_upload_slots.js #}
{% macro journal_upload_slots() -%}
<div class="journal-upload-slots" id="journal-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %}
<label class="journal-upload-slot">
<span class="journal-upload-slot-label">{{ tf }}</span>
<input
type="file"
name="screenshot_{{ tf }}"
accept="image/*"
class="journal-upload-slot-input"
data-tf="{{ tf }}"
>
<div class="journal-upload-slot-preview" data-tf="{{ tf }}" aria-hidden="true"></div>
</label>
{% endfor %}
</div>
<p class="sub journal-upload-hint">可只传部分周期;保存后按 5m / 15m / 1h / 4h 命名,详情页四宫格查看</p>
{%- endmacro %}
+91
View File
@@ -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()