ui: 复盘表单紧凑布局、即时上传截图并移除占用时新开仓
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || "无")}`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user