ui: 复盘表单紧凑布局、即时上传截图并移除占用时新开仓

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-05 22:46:05 +08:00
parent 7bb6ee1154
commit f688a504e1
17 changed files with 544 additions and 263 deletions
+18 -4
View File
@@ -76,12 +76,14 @@ from lib.strategy.strategy_trade_labels import (
trend_plan_id_from_monitor_row, trend_plan_id_from_monitor_row,
) )
from lib.instance.journal_images_lib import ( from lib.instance.journal_images_lib import (
collect_journal_slot_images,
enrich_journal_api_item, enrich_journal_api_item,
images_json_dumps, images_json_dumps,
journal_image_paths, journal_image_paths,
normalize_journal_draft_id,
primary_journal_image, 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 ( from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF1,
@@ -9088,8 +9090,9 @@ def add_journal():
early_exit_raw = "" if early_exit_trigger == "手动平仓" else "" early_exit_raw = "" if early_exit_trigger == "手动平仓" else ""
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) 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) exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
entry_id = uuid.uuid4().hex entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex
manual_images = save_journal_slot_uploads( manual_images = collect_journal_slot_images(
d,
request.files, request.files,
entry_id, entry_id,
app.config["UPLOAD_FOLDER"], app.config["UPLOAD_FOLDER"],
@@ -9177,7 +9180,7 @@ def add_journal():
d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, 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, early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
None, None, None, mood_issues, 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, images_json_str,
) )
) )
@@ -9200,6 +9203,17 @@ def add_journal():
return _redirect_records() 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") @app.route("/api/journals")
@login_required @login_required
def api_journals(): def api_journals():
+8 -36
View File
@@ -243,7 +243,7 @@
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} .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} .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
</style> </style>
<link rel="stylesheet" href="/static/instance_theme.css?v=50"> <link rel="stylesheet" href="/static/instance_theme.css?v=52">
</head> </head>
<body <body
@@ -652,39 +652,11 @@
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint"> <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="exit_price_hint" id="exit-price-hint">
<input type="hidden" name="direction_hint" id="direction-hint"> <input type="hidden" name="direction_hint" id="direction-hint">
<div class="form-grid"> {% from 'journal_form_fields.html' import journal_form_fields %}
<input type="datetime-local" name="open_datetime" required> {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }}
<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_upload_slots.html' import journal_upload_slots %} {% from 'journal_upload_slots.html' import journal_upload_slots %}
{{ 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"> <label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="journal_exchange_chart" value="true"> <input type="checkbox" name="journal_exchange_chart" value="true">
保存时自动生成 K 线图并作为截图 保存时自动生成 K 线图并作为截图
@@ -713,8 +685,8 @@
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option> <option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select> </select>
</div> </div>
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div> <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" style="margin-top:8px"> <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> <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>
@@ -820,8 +792,8 @@
</div> </div>
</div> </div>
<script src="/static/instance_ui.js?v=5"></script> <script src="/static/instance_ui.js?v=6"></script>
<script src="/static/journal_upload_slots.js?v=1"></script> <script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/instance_records_mobile.js?v=2"></script> <script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=2"></script> <script src="/static/time_close_ui.js?v=2"></script>
<script src="/static/ai_review_render.js?v=2"></script> <script src="/static/ai_review_render.js?v=2"></script>
+18 -4
View File
@@ -77,12 +77,14 @@ from lib.strategy.strategy_trade_labels import (
trend_plan_id_from_monitor_row, trend_plan_id_from_monitor_row,
) )
from lib.instance.journal_images_lib import ( from lib.instance.journal_images_lib import (
collect_journal_slot_images,
enrich_journal_api_item, enrich_journal_api_item,
images_json_dumps, images_json_dumps,
journal_image_paths, journal_image_paths,
normalize_journal_draft_id,
primary_journal_image, 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 ( from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF1,
@@ -8910,8 +8912,9 @@ def add_journal():
early_exit_raw = "" if early_exit_trigger == "手动平仓" else "" early_exit_raw = "" if early_exit_trigger == "手动平仓" else ""
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) 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) exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
entry_id = uuid.uuid4().hex entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex
manual_images = save_journal_slot_uploads( manual_images = collect_journal_slot_images(
d,
request.files, request.files,
entry_id, entry_id,
app.config["UPLOAD_FOLDER"], app.config["UPLOAD_FOLDER"],
@@ -8999,7 +9002,7 @@ def add_journal():
d.get("pnl"), entry_reason_norm, exit_reason_stored, d.get("expect_rr"), real_rr_text, 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, early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
None, None, None, mood_issues, 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, images_json_str,
) )
) )
@@ -9022,6 +9025,17 @@ def add_journal():
return _redirect_records() 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") @app.route("/api/journals")
@login_required @login_required
def api_journals(): def api_journals():
+8 -36
View File
@@ -243,7 +243,7 @@
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} .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} .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
</style> </style>
<link rel="stylesheet" href="/static/instance_theme.css?v=50"> <link rel="stylesheet" href="/static/instance_theme.css?v=52">
</head> </head>
<body <body
@@ -619,39 +619,11 @@
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint"> <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="exit_price_hint" id="exit-price-hint">
<input type="hidden" name="direction_hint" id="direction-hint"> <input type="hidden" name="direction_hint" id="direction-hint">
<div class="form-grid"> {% from 'journal_form_fields.html' import journal_form_fields %}
<input type="datetime-local" name="open_datetime" required> {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }}
<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_upload_slots.html' import journal_upload_slots %} {% from 'journal_upload_slots.html' import journal_upload_slots %}
{{ 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"> <label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="journal_exchange_chart" value="true"> <input type="checkbox" name="journal_exchange_chart" value="true">
保存时自动生成 K 线图并作为截图 保存时自动生成 K 线图并作为截图
@@ -680,8 +652,8 @@
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option> <option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select> </select>
</div> </div>
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div> <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" style="margin-top:8px"> <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> <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>
@@ -787,8 +759,8 @@
</div> </div>
</div> </div>
<script src="/static/instance_ui.js?v=5"></script> <script src="/static/instance_ui.js?v=6"></script>
<script src="/static/journal_upload_slots.js?v=1"></script> <script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/instance_records_mobile.js?v=2"></script> <script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=2"></script> <script src="/static/time_close_ui.js?v=2"></script>
<script src="/static/ai_review_render.js?v=2"></script> <script src="/static/ai_review_render.js?v=2"></script>
+18 -4
View File
@@ -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.exchange.okx_orders_lib import cancel_okx_all_open_orders, fetch_okx_all_open_orders
from lib.instance.journal_images_lib import ( from lib.instance.journal_images_lib import (
collect_journal_slot_images,
enrich_journal_api_item, enrich_journal_api_item,
images_json_dumps, images_json_dumps,
journal_image_paths, journal_image_paths,
normalize_journal_draft_id,
primary_journal_image, 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 ( from lib.instance.journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT, JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1, JOURNAL_CHART_DEFAULT_TF1,
@@ -8459,8 +8461,9 @@ def add_journal():
early_exit_raw = "" if early_exit_trigger == "手动平仓" else "" early_exit_raw = "" if early_exit_trigger == "手动平仓" else ""
early_exit_reason_saved = compose_early_exit_reason_saved(early_exit_trigger, early_exit_note) 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) exit_reason_stored = journal_exit_reason_stored(early_exit_trigger, early_exit_note)
entry_id = uuid.uuid4().hex entry_id = normalize_journal_draft_id(d.get("journal_draft_id")) or uuid.uuid4().hex
manual_images = save_journal_slot_uploads( manual_images = collect_journal_slot_images(
d,
request.files, request.files,
entry_id, entry_id,
app.config["UPLOAD_FOLDER"], 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, 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, early_exit_raw, early_exit_reason_saved, early_exit_trigger, early_exit_note,
None, None, None, mood_issues, 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, images_json_str,
) )
) )
@@ -8571,6 +8574,17 @@ def add_journal():
return _redirect_records() 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") @app.route("/api/journals")
@login_required @login_required
def api_journals(): def api_journals():
+8 -36
View File
@@ -243,7 +243,7 @@
.stats-period-block h3{font-size:1rem;color:#dbe4ff;margin-bottom:4px} .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} .stats-period-block .sub{font-size:.78rem;color:#8892b0;margin-bottom:10px;line-height:1.4}
</style> </style>
<link rel="stylesheet" href="/static/instance_theme.css?v=50"> <link rel="stylesheet" href="/static/instance_theme.css?v=52">
</head> </head>
<body <body
@@ -648,39 +648,11 @@
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint"> <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="exit_price_hint" id="exit-price-hint">
<input type="hidden" name="direction_hint" id="direction-hint"> <input type="hidden" name="direction_hint" id="direction-hint">
<div class="form-grid"> {% from 'journal_form_fields.html' import journal_form_fields %}
<input type="datetime-local" name="open_datetime" required> {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }}
<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_upload_slots.html' import journal_upload_slots %} {% from 'journal_upload_slots.html' import journal_upload_slots %}
{{ 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"> <label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="journal_exchange_chart" value="true"> <input type="checkbox" name="journal_exchange_chart" value="true">
保存时自动生成 K 线图并作为截图 保存时自动生成 K 线图并作为截图
@@ -709,8 +681,8 @@
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option> <option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select> </select>
</div> </div>
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div> <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" style="margin-top:8px"> <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> <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>
@@ -816,8 +788,8 @@
</div> </div>
</div> </div>
<script src="/static/instance_ui.js?v=5"></script> <script src="/static/instance_ui.js?v=6"></script>
<script src="/static/journal_upload_slots.js?v=1"></script> <script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/instance_records_mobile.js?v=2"></script> <script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=2"></script> <script src="/static/time_close_ui.js?v=2"></script>
<script src="/static/ai_review_render.js?v=2"></script> <script src="/static/ai_review_render.js?v=2"></script>
-2
View File
@@ -78,8 +78,6 @@ def journal_row_lines_for_ai(
lines.append(f" {mood_bits}") lines.append(f" {mood_bits}")
if _journal_nz(_row_get(row, "post_breakeven_stare")) != "": if _journal_nz(_row_get(row, "post_breakeven_stare")) != "":
lines.append(f" 保本后盯盘:{_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")) != "": if _journal_nz(_row_get(row, "note")) != "":
lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}") lines.append(f" 备注:{_journal_nz(_row_get(row, 'note'))}")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
+91 -45
View File
@@ -1660,63 +1660,118 @@ html[data-theme="light"] .symbol-live-price--ok {
} }
/* ── 复盘:四周期截图上传 / 详情四宫格 ── */ /* ── 复盘:四周期截图上传 / 详情四宫格 ── */
.journal-upload-slots { .journal-card .journal-form-row1 {
display: grid; grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 6px;
gap: 10px;
margin-top: 10px;
} }
.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; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; align-items: stretch;
padding: 8px 10px; gap: 4px;
border: 1px dashed rgba(130, 145, 190, 0.45); flex: 1 1 0;
border-radius: 8px; min-width: 0;
background: rgba(12, 16, 32, 0.35);
font-size: 0.82rem;
} }
.journal-upload-slot-label { .journal-upload-slot-label {
color: #9aa3c7; color: #9aa3c7;
font-weight: 600; font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.journal-upload-slot-input { .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%; width: 100%;
max-height: 140px; min-width: 0;
object-fit: contain; font-size: 0.72rem;
cursor: zoom-in; padding: 3px 4px;
border-radius: 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 { .journal-upload-hint {
margin-top: 6px; margin-top: 4px;
margin-bottom: 0;
font-size: 0.72rem; font-size: 0.72rem;
color: #8892b0; 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 { .journal-detail-images {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1747,15 +1802,6 @@ html[data-theme="light"] .symbol-live-price--ok {
cursor: zoom-in; 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 { html[data-theme="light"] .journal-detail-images {
border-top-color: #d0dae4; border-top-color: #d0dae4;
} }
-1
View File
@@ -43,7 +43,6 @@
`预期RR${escapeHtml(o.expect_rr || "-")}`, `预期RR${escapeHtml(o.expect_rr || "-")}`,
`实际RR${escapeHtml(o.real_rr || "-")}`, `实际RR${escapeHtml(o.real_rr || "-")}`,
`保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`, `保本后盯盘:${escapeHtml(o.post_breakeven_stare || "-")}`,
`占用时新开仓:${escapeHtml(o.new_trade_while_occupied || "-")}`,
`心态标签:${escapeHtml(moodTags)}`, `心态标签:${escapeHtml(moodTags)}`,
`备注:${escapeHtml(o.note || "无")}`, `备注:${escapeHtml(o.note || "无")}`,
]; ];
+110 -34
View File
@@ -1,63 +1,139 @@
/** /**
* 复盘表单四周期截图槽位本地预览 * 复盘表单四周期截图即时上传与状态展示
*/ */
(function (global) { (function (global) {
"use strict"; "use strict";
const previewUrls = new WeakMap(); function newDraftId() {
if (global.crypto && typeof global.crypto.randomUUID === "function") {
function clearPreview(cell) { return global.crypto.randomUUID().replace(/-/g, "");
if (!cell) return;
const prev = previewUrls.get(cell);
if (prev) {
URL.revokeObjectURL(prev);
previewUrls.delete(cell);
} }
cell.innerHTML = ""; var s = "";
cell.classList.remove("journal-upload-slot-preview--filled"); for (var i = 0; i < 32; i++) {
s += Math.floor(Math.random() * 16).toString(16);
}
return s;
} }
function renderPreview(cell, file, tf) { function ensureDraftId(root) {
clearPreview(cell); var scope = root || document;
if (!file || !cell) return; var el = scope.querySelector("#journal-draft-id");
const url = URL.createObjectURL(file); if (!el) return "";
previewUrls.set(cell, url); if (!el.value) {
const img = document.createElement("img"); el.value = newDraftId();
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);
} }
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");
}); });
cell.appendChild(img);
cell.classList.add("journal-upload-slot-preview--filled");
} }
function bindInput(input) { function bindInput(input) {
if (!input || input.dataset.journalSlotBound === "1") return; if (!input || input.dataset.journalSlotBound === "1") return;
input.dataset.journalSlotBound = "1"; 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 () { input.addEventListener("change", function () {
const file = input.files && input.files[0]; var file = input.files && input.files[0];
if (!file) { if (!file) {
clearPreview(preview); var parts = rowParts(input);
if (parts.hidden) {
parts.hidden.value = "";
}
setStatus(parts.status, "", "");
return; 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) { function init(root) {
const scope = root || document; var scope = root || document;
ensureDraftId(scope);
scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput); scope.querySelectorAll(".journal-upload-slot-input").forEach(bindInput);
} }
global.JournalUploadSlots = { init: init }; global.JournalUploadSlots = { init: init, reset: resetSlots };
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
+105 -16
View File
@@ -3,21 +3,119 @@ from __future__ import annotations
import json import json
import os import os
import re
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h") JOURNAL_UPLOAD_TFS: tuple[str, ...] = ("5m", "15m", "1h", "4h")
JOURNAL_UPLOAD_ALLOWED_EXT = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}) 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: def journal_upload_field_name(tf: str) -> str:
return f"screenshot_{tf}" 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: def _safe_ext(filename: str) -> str:
ext = os.path.splitext(str(filename or ""))[1].lower() ext = os.path.splitext(str(filename or ""))[1].lower()
return ext if ext in JOURNAL_UPLOAD_ALLOWED_EXT else ".png" 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( def save_journal_slot_uploads(
files, files,
entry_id: str, entry_id: str,
@@ -26,22 +124,13 @@ def save_journal_slot_uploads(
secure_filename_fn: Callable[[str], str], secure_filename_fn: Callable[[str], str],
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
"""保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...]。""" """保存四槽位手动截图,返回 [{"tf":"5m","file":"journal_xxx_5m.png"}, ...]。"""
saved: List[Dict[str, str]] = [] return collect_journal_slot_images(
if not entry_id or not upload_folder: None,
return saved files,
os.makedirs(upload_folder, exist_ok=True) entry_id,
for tf in JOURNAL_UPLOAD_TFS: upload_folder,
f = files.get(journal_upload_field_name(tf)) if files else None secure_filename_fn=secure_filename_fn,
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]: def images_json_dumps(items: Sequence[Mapping[str, str]]) -> Optional[str]:
+43
View File
@@ -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="stop_loss_hint" id="stop-loss-hint">
<input type="hidden" name="exit_price_hint" id="exit-price-hint"> <input type="hidden" name="exit_price_hint" id="exit-price-hint">
<input type="hidden" name="direction_hint" id="direction-hint"> <input type="hidden" name="direction_hint" id="direction-hint">
<div class="form-grid"> {% from 'journal_form_fields.html' import journal_form_fields %}
<input type="datetime-local" name="open_datetime" required> {{ journal_form_fields(entry_reason_options, entry_reason_other_value) }}
<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_upload_slots.html' import journal_upload_slots %} {% from 'journal_upload_slots.html' import journal_upload_slots %}
{{ 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"> <label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="journal_exchange_chart" value="true"> <input type="checkbox" name="journal_exchange_chart" value="true">
保存时自动生成 K 线图并作为截图 保存时自动生成 K 线图并作为截图
@@ -357,8 +329,8 @@
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option> <option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select> </select>
</div> </div>
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div> <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" style="margin-top:8px"> <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> <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>
+3 -3
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <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/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=2"> <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> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title> <title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
@@ -115,8 +115,8 @@
</div> </div>
</div> </div>
<script src="/static/instance_ui.js?v=5"></script> <script src="/static/instance_ui.js?v=6"></script>
<script src="/static/journal_upload_slots.js?v=1"></script> <script src="/static/journal_upload_slots.js?v=3"></script>
<script src="/static/instance_records_mobile.js?v=2"></script> <script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=2"></script> <script src="/static/time_close_ui.js?v=2"></script>
<script src="/static/ai_review_render.js?v=2"></script> <script src="/static/ai_review_render.js?v=2"></script>
@@ -0,0 +1,34 @@
{# 复盘表单字段两行布局 #}
{% macro journal_form_fields(entry_reason_options, entry_reason_other_value) -%}
<div class="form-grid journal-form-row1">
<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>
<input name="expect_rr" placeholder="预期RR">
<input name="real_rr" placeholder="实际RR">
</div>
<div class="form-grid journal-form-row2">
<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>
<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>
</div>
<input type="text" name="entry_reason_custom" id="journal-entry-reason-custom" maxlength="2000" placeholder="选「其他」时在此填写开仓类型说明" autocomplete="off" style="display:none;width:100%;margin-top:4px;font-size:.8rem">
{%- endmacro %}
@@ -1,19 +1,20 @@
{# 复盘四周期截图槽位(须加载 journal_upload_slots.js #} {# 复盘四周期截图槽位(须加载 journal_upload_slots.js #}
{% macro journal_upload_slots() -%} {% macro journal_upload_slots() -%}
<input type="hidden" name="journal_draft_id" id="journal-draft-id" value="">
<div class="journal-upload-slots" id="journal-upload-slots"> <div class="journal-upload-slots" id="journal-upload-slots">
{% for tf in ['5m', '15m', '1h', '4h'] %} {% for tf in ['5m', '15m', '1h', '4h'] %}
<label class="journal-upload-slot"> <div class="journal-upload-row" data-tf="{{ tf }}">
<span class="journal-upload-slot-label">{{ tf }}</span> <span class="journal-upload-slot-label">{{ tf }}</span>
<input <input
type="file" type="file"
name="screenshot_{{ tf }}"
accept="image/*" accept="image/*"
class="journal-upload-slot-input" class="journal-upload-slot-input"
data-tf="{{ tf }}" data-tf="{{ tf }}"
> >
<div class="journal-upload-slot-preview" data-tf="{{ tf }}" aria-hidden="true"></div> <input type="hidden" name="uploaded_screenshot_{{ tf }}" class="journal-upload-hidden-file" data-tf="{{ tf }}" value="">
</label> <span class="journal-upload-status" data-tf="{{ tf }}" aria-live="polite"></span>
</div>
{% endfor %} {% endfor %}
</div> </div>
<p class="sub journal-upload-hint">可只传部分周期;保存后按 5m / 15m / 1h / 4h 命名,详情页四宫格查看</p> <p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传,保存后详情页四宫格查看</p>
{%- endmacro %} {%- endmacro %}
+67 -2
View File
@@ -1,4 +1,4 @@
"""journal_images_lib 单元测试。""" """journal_images_lib / journal_upload_api_lib 单元测试。"""
import json import json
import os import os
import tempfile import tempfile
@@ -7,14 +7,19 @@ from io import BytesIO
from lib.instance.journal_images_lib import ( from lib.instance.journal_images_lib import (
JOURNAL_UPLOAD_TFS, JOURNAL_UPLOAD_TFS,
collect_journal_slot_images,
enrich_journal_api_item, enrich_journal_api_item,
images_json_dumps, images_json_dumps,
is_valid_preuploaded_journal_file,
journal_image_paths, journal_image_paths,
journal_upload_field_name, journal_upload_field_name,
normalize_journal_draft_id,
parse_images_json, parse_images_json,
primary_journal_image, primary_journal_image,
save_journal_slot_uploads, save_journal_slot_uploads,
uploaded_screenshot_field_name,
) )
from lib.instance.journal_upload_api_lib import handle_journal_upload_slot
class _FakeFile: class _FakeFile:
@@ -35,9 +40,29 @@ class _FakeFiles:
return self._mapping.get(key) 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): class JournalImagesLibTest(unittest.TestCase):
def test_field_names(self): def test_field_names(self):
self.assertEqual(journal_upload_field_name("5m"), "screenshot_5m") 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): def test_save_slot_uploads_partial(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
@@ -49,7 +74,7 @@ class JournalImagesLibTest(unittest.TestCase):
) )
saved = save_journal_slot_uploads( saved = save_journal_slot_uploads(
files, files,
"abc123", "abc123" + "0" * 26,
tmp, tmp,
secure_filename_fn=lambda x: x, 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.assertTrue(os.path.isfile(os.path.join(tmp, saved[0]["file"])))
self.assertEqual(saved[1]["tf"], "1h") 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): def test_parse_and_enrich(self):
raw = images_json_dumps([{"tf": "5m", "file": "journal_x_5m.png"}]) raw = images_json_dumps([{"tf": "5m", "file": "journal_x_5m.png"}])
item = enrich_journal_api_item({"images_json": raw, "image": "legacy.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([])) 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__": if __name__ == "__main__":
unittest.main() unittest.main()