This commit is contained in:
dekun
2026-05-27 22:35:51 +08:00
parent bb8aca0cb3
commit fe068709ac
10 changed files with 337 additions and 27 deletions
+22 -1
View File
@@ -36,6 +36,7 @@ if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from fib_key_monitor_lib import (
FIB_KEY_MONITOR_TYPES,
calc_fib_plan,
@@ -6476,6 +6477,12 @@ def add_key():
return redirect("/key_monitor")
mt = (d.get("type") or "").strip()
direction_sel = (d.get("direction") or "").strip().lower()
dup_msg = check_duplicate_submit(
session, submit_scope_add_key(symbol, mt, direction_sel or "watch")
)
if dup_msg:
flash(dup_msg)
return redirect("/key_monitor")
if mt in KEY_MONITOR_RS_TYPES:
direction_sel = KEY_DIRECTION_WATCH
elif direction_sel not in ("long", "short"):
@@ -6622,6 +6629,11 @@ def add_order():
conn.close()
flash("symbol 不能为空")
return redirect("/")
dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction))
if dup_msg:
conn.close()
flash(dup_msg)
return redirect("/trade")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "已达最大持仓数" in reason:
@@ -7517,7 +7529,9 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_AI_REVIEW_RENDER_JS = os.path.join(os.path.dirname(BASE_DIR), "static", "ai_review_render.js")
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
@app.route("/static/ai_review_render.js")
@@ -7527,6 +7541,13 @@ def static_ai_review_render_js():
return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/static/form_submit_guard.js")
def static_form_submit_guard_js():
if not os.path.isfile(_FORM_SUBMIT_GUARD_JS):
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/export/review_md/<rid>")
@login_required
def export_review_md(rid):
+35 -7
View File
@@ -61,6 +61,8 @@
.pnl-profit{color:#4cd97f;font-weight:600}
.pnl-loss{color:#ff6666;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
@@ -862,6 +864,7 @@
</div>
<script src="/static/ai_review_render.js?v=1"></script>
<script src="/static/form_submit_guard.js?v=1"></script>
<script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
@@ -1586,27 +1589,35 @@ const keyForm = document.getElementById("key-form");
if(keyForm){
keyForm.addEventListener("submit", (e)=>{
e.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if(!symbol){
alert("请先输入交易对");
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then(r=>r.json().then(d=>({status:r.status, data:d})))
.then(({status,data})=>{
if(status >= 400 || !data.ok){
alert((data && data.msg) || "日成交量排名读取失败");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
if(!data.in_top30){
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
keyForm.submit();
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
else keyForm.submit();
})
.catch(()=>alert("日成交量排名检查失败,请稍后重试"));
.catch(()=>{
alert("日成交量排名检查失败,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
});
});
}
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
@@ -1734,8 +1745,10 @@ function cancelExchangeTpsl(orderId, role){
}
function allowManualOrderSubmit(form){
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(form) && form.dataset.rrOk !== "1") return;
form.dataset.rrOk = "1";
form.submit();
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(form, "开仓提交中…");
else form.submit();
}
let latestAvailableUsdt = null;
@@ -1950,23 +1963,32 @@ if(addOrderForm){
return;
}
ev.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return;
const direction = (document.getElementById("order-direction")||{}).value || "long";
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim();
if(mode === "pct"){
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
const rr = calcClientRrFromPct(
(document.getElementById("order-sl-pct")||{}).value,
(document.getElementById("order-tp-pct")||{}).value
);
if(rejectManualOrderRr(rr)) return;
if(rejectManualOrderRr(rr)){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
return;
}
const sl = Number((document.getElementById("order-sl")||{}).value);
const tp = Number((document.getElementById("order-tp")||{}).value);
let entry = sl;
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
if(!symbol){
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return;
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
return;
}
@@ -1975,10 +1997,16 @@ if(addOrderForm){
.then(data=>{
const px = data.last_price || data.price;
if(px) entry = Number(px);
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return;
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
})
.catch(()=>{ alert("无法校验盈亏比,请稍后重试"); });
.catch(()=>{
alert("无法校验盈亏比,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
});
});
}
+23 -1
View File
@@ -36,6 +36,7 @@ if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from fib_key_monitor_lib import (
FIB_KEY_MONITOR_TYPES,
KEY_ENTRY_REASON_BY_SIGNAL,
@@ -6526,6 +6527,13 @@ def add_key():
flash("symbol 不能为空")
return redirect("/key_monitor")
mt = (d.get("type") or "").strip()
direction_pre = (d.get("direction") or "").strip().lower()
dup_msg = check_duplicate_submit(
session, submit_scope_add_key(symbol, mt, direction_pre or "watch")
)
if dup_msg:
flash(dup_msg)
return redirect("/key_monitor")
direction_sel = (d.get("direction") or "").strip().lower()
if mt in KEY_MONITOR_RS_TYPES:
direction_sel = KEY_DIRECTION_WATCH
@@ -6696,6 +6704,11 @@ def add_order():
conn.close()
flash("symbol 不能为空")
return redirect("/")
dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction))
if dup_msg:
conn.close()
flash(dup_msg)
return redirect("/trade")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "已达最大持仓数" in reason:
@@ -7597,7 +7610,9 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_AI_REVIEW_RENDER_JS = os.path.join(os.path.dirname(BASE_DIR), "static", "ai_review_render.js")
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
@app.route("/static/ai_review_render.js")
@@ -7607,6 +7622,13 @@ def static_ai_review_render_js():
return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/static/form_submit_guard.js")
def static_form_submit_guard_js():
if not os.path.isfile(_FORM_SUBMIT_GUARD_JS):
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/export/review_md/<rid>")
@login_required
def export_review_md(rid):
+35 -7
View File
@@ -61,6 +61,8 @@
.pnl-profit{color:#4cd97f;font-weight:600}
.pnl-loss{color:#ff6666;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
@@ -862,6 +864,7 @@
</div>
<script src="/static/ai_review_render.js?v=1"></script>
<script src="/static/form_submit_guard.js?v=1"></script>
<script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
@@ -1586,27 +1589,35 @@ const keyForm = document.getElementById("key-form");
if(keyForm){
keyForm.addEventListener("submit", (e)=>{
e.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if(!symbol){
alert("请先输入交易对");
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then(r=>r.json().then(d=>({status:r.status, data:d})))
.then(({status,data})=>{
if(status >= 400 || !data.ok){
alert((data && data.msg) || "日成交量排名读取失败");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
if(!data.in_top30){
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前${rankMax},已拦截。`);
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
keyForm.submit();
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
else keyForm.submit();
})
.catch(()=>alert("日成交量排名检查失败,请稍后重试"));
.catch(()=>{
alert("日成交量排名检查失败,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
});
});
}
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
@@ -1734,8 +1745,10 @@ function cancelExchangeTpsl(orderId, role){
}
function allowManualOrderSubmit(form){
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(form) && form.dataset.rrOk !== "1") return;
form.dataset.rrOk = "1";
form.submit();
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(form, "开仓提交中…");
else form.submit();
}
let latestAvailableUsdt = null;
@@ -1950,23 +1963,32 @@ if(addOrderForm){
return;
}
ev.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return;
const direction = (document.getElementById("order-direction")||{}).value || "long";
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim();
if(mode === "pct"){
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
const rr = calcClientRrFromPct(
(document.getElementById("order-sl-pct")||{}).value,
(document.getElementById("order-tp-pct")||{}).value
);
if(rejectManualOrderRr(rr)) return;
if(rejectManualOrderRr(rr)){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
return;
}
const sl = Number((document.getElementById("order-sl")||{}).value);
const tp = Number((document.getElementById("order-tp")||{}).value);
let entry = sl;
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
if(!symbol){
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return;
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
return;
}
@@ -1975,10 +1997,16 @@ if(addOrderForm){
.then(data=>{
const px = data.last_price || data.price;
if(px) entry = Number(px);
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return;
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
})
.catch(()=>{ alert("无法校验盈亏比,请稍后重试"); });
.catch(()=>{
alert("无法校验盈亏比,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
});
});
}
+24 -1
View File
@@ -36,6 +36,7 @@ if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from journal_chart_lib import (
JOURNAL_CHART_DEFAULT_LIMIT,
JOURNAL_CHART_DEFAULT_TF1,
@@ -5825,6 +5826,14 @@ def add_key():
if not symbol:
flash("symbol 不能为空")
return redirect("/")
mt = (d.get("type") or "").strip()
direction_pre = (d.get("direction") or "long").strip().lower()
dup_msg = check_duplicate_submit(
session, submit_scope_add_key(symbol, mt, direction_pre)
)
if dup_msg:
flash(dup_msg)
return redirect("/")
rank, total = _daily_volume_rank(symbol)
if rank is None:
flash("日成交量排名读取失败,请稍后重试")
@@ -5852,6 +5861,11 @@ def add_order():
conn.close()
flash("symbol 不能为空")
return redirect("/")
dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction))
if dup_msg:
conn.close()
flash(dup_msg)
return redirect("/trade")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "已达最大持仓数" in reason:
@@ -7004,7 +7018,9 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_AI_REVIEW_RENDER_JS = os.path.join(os.path.dirname(BASE_DIR), "static", "ai_review_render.js")
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
@app.route("/static/ai_review_render.js")
@@ -7014,6 +7030,13 @@ def static_ai_review_render_js():
return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/static/form_submit_guard.js")
def static_form_submit_guard_js():
if not os.path.isfile(_FORM_SUBMIT_GUARD_JS):
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/export/review_md/<rid>")
@login_required
def export_review_md(rid):
+28 -3
View File
@@ -62,6 +62,8 @@
.pnl-loss{color:#ff6666;font-weight:600}
.pnl-neutral{color:#cfd3ef;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
@@ -318,7 +320,7 @@
</select>
<button type="submit">手动划转</button>
</form>
<form action="/add_order" method="post" class="form-row">
<form id="add-order-form" action="/add_order" method="post" class="form-row">
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select id="order-direction" name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
@@ -701,6 +703,7 @@
</div>
<script src="/static/ai_review_render.js?v=1"></script>
<script src="/static/form_submit_guard.js?v=1"></script>
<script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
@@ -1392,26 +1395,48 @@ const keyForm = document.getElementById("key-form");
if(keyForm){
keyForm.addEventListener("submit", (e)=>{
e.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if(!symbol){
alert("请先输入交易对");
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then(r=>r.json().then(d=>({status:r.status, data:d})))
.then(({status,data})=>{
if(status >= 400 || !data.ok){
alert((data && data.msg) || "日成交量排名读取失败");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
if(!data.in_top30){
alert(`${data.symbol} 当前日成交量排名 ${data.rank}/${data.total},不在前30,已拦截。`);
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
keyForm.submit();
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
else keyForm.submit();
})
.catch(()=>alert("日成交量排名检查失败,请稍后重试"));
.catch(()=>{
alert("日成交量排名检查失败,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
});
});
}
const addOrderForm = document.getElementById("add-order-form");
if(addOrderForm){
addOrderForm.addEventListener("submit", function(ev){
if(addOrderForm.dataset.submitOnce === "1"){
addOrderForm.dataset.submitOnce = "0";
return;
}
ev.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return;
addOrderForm.dataset.submitOnce = "1";
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(addOrderForm, "开仓提交中…");
else addOrderForm.submit();
});
}
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
+22 -1
View File
@@ -36,6 +36,7 @@ if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from ai_client import ai_generate, ai_review, ai_short_advice
from ai_review_lib import build_journal_ai_chart_path, collect_images_for_ai_review
from form_submit_lib import check_duplicate_submit, submit_scope_add_key, submit_scope_add_order
from fib_key_monitor_lib import (
FIB_KEY_MONITOR_TYPES,
calc_fib_plan,
@@ -5926,6 +5927,12 @@ def add_key():
return redirect("/key_monitor")
mt = (d.get("type") or "").strip()
direction_sel = (d.get("direction") or "").strip().lower()
dup_msg = check_duplicate_submit(
session, submit_scope_add_key(symbol, mt, direction_sel or "watch")
)
if dup_msg:
flash(dup_msg)
return redirect("/key_monitor")
if mt in KEY_MONITOR_RS_TYPES:
direction_sel = KEY_DIRECTION_WATCH
elif direction_sel not in ("long", "short"):
@@ -6056,6 +6063,11 @@ def add_order():
conn.close()
flash("symbol 不能为空")
return redirect("/trade")
dup_msg = check_duplicate_submit(session, submit_scope_add_order(symbol, direction))
if dup_msg:
conn.close()
flash(dup_msg)
return redirect("/trade")
ok, reason = precheck_risk(conn, symbol, direction)
if not ok:
if "已达最大持仓数" in reason or "一次只能持有一个仓位" in reason:
@@ -6912,7 +6924,9 @@ def api_reviews():
return jsonify([row_to_dict(r) for r in rows])
_AI_REVIEW_RENDER_JS = os.path.join(os.path.dirname(BASE_DIR), "static", "ai_review_render.js")
_REPO_STATIC_DIR = os.path.join(os.path.dirname(BASE_DIR), "static")
_AI_REVIEW_RENDER_JS = os.path.join(_REPO_STATIC_DIR, "ai_review_render.js")
_FORM_SUBMIT_GUARD_JS = os.path.join(_REPO_STATIC_DIR, "form_submit_guard.js")
@app.route("/static/ai_review_render.js")
@@ -6922,6 +6936,13 @@ def static_ai_review_render_js():
return send_file(_AI_REVIEW_RENDER_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/static/form_submit_guard.js")
def static_form_submit_guard_js():
if not os.path.isfile(_FORM_SUBMIT_GUARD_JS):
return Response("not found", status=404, mimetype="text/plain; charset=utf-8")
return send_file(_FORM_SUBMIT_GUARD_JS, mimetype="application/javascript; charset=utf-8")
@app.route("/export/review_md/<rid>")
@login_required
def export_review_md(rid):
+32 -6
View File
@@ -61,6 +61,8 @@
.pnl-profit{color:#4cd97f;font-weight:600}
.pnl-loss{color:#ff6666;font-weight:600}
.flash{padding:10px;background:#1e2533;color:#4cc2ff;border-radius:10px;margin-bottom:12px;text-align:center;border:1px solid #304164}
form.is-form-submitting{opacity:.88;pointer-events:none}
form.is-form-submitting button[type=submit],form.is-form-submitting input[type=submit]{cursor:wait}
.ai-result{background:#1a1a29;border:1px solid #2e2e45;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:220px;overflow:auto;font-size:.84rem;line-height:1.45;margin-top:8px}
.ai-result.ai-result-md,.detail-modal .panel-body.md-review{white-space:normal}
.ai-result-md p,.detail-modal .panel-body.md-review p{margin:6px 0;color:#dde2ff}
@@ -871,6 +873,7 @@
</div>
<script src="/static/ai_review_render.js?v=1"></script>
<script src="/static/form_submit_guard.js?v=1"></script>
<script>
const JOURNAL_ENTRY_REASON_OPTIONS = {{ entry_reason_options | tojson }};
const JOURNAL_ENTRY_REASON_OTHER = {{ entry_reason_other_value | tojson }};
@@ -1595,28 +1598,36 @@ const keyForm = document.getElementById("key-form");
if(keyForm){
keyForm.addEventListener("submit", (e)=>{
e.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(keyForm)) return;
const symbolEl = keyForm.querySelector('[name="symbol"]');
const symbol = (symbolEl ? symbolEl.value : "").trim();
if(!symbol){
alert("请先输入交易对");
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(keyForm, "校验排名中…");
fetch(`/api/symbol_liquidity_rank?symbol=${encodeURIComponent(symbol)}`)
.then(r=>r.json().then(d=>({status:r.status, data:d})))
.then(({status,data})=>{
if(status >= 400 || !data.ok){
alert((data && data.msg) || "日成交量排名读取失败");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
const rankMax = data.rank_max || 30;
const inTop = data.in_top != null ? data.in_top : data.in_top30;
if(data.rank == null || !inTop){
alert(`${data.symbol} 当前24h成交额排名 ${data.rank == null ? "—" : data.rank}/${data.total},不在前${rankMax},已拦截。`);
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
return;
}
keyForm.submit();
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(keyForm, "提交中…");
else keyForm.submit();
})
.catch(()=>alert("日成交量排名检查失败,请稍后重试"));
.catch(()=>{
alert("日成交量排名检查失败,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(keyForm);
});
});
}
// 复盘/AI列表:初次进入页面后再异步刷新一次,避免浏览器 bfcache/重定向后仍显示旧缓存
@@ -1744,8 +1755,10 @@ function cancelExchangeTpsl(orderId, role){
}
function allowManualOrderSubmit(form){
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(form) && form.dataset.rrOk !== "1") return;
form.dataset.rrOk = "1";
form.submit();
if(window.FormSubmitGuard) FormSubmitGuard.nativeSubmitOnce(form, "开仓提交中…");
else form.submit();
}
let latestAvailableUsdt = null;
@@ -1992,15 +2005,20 @@ if(addOrderForm){
return;
}
ev.preventDefault();
if(window.FormSubmitGuard && FormSubmitGuard.isLocked(addOrderForm)) return;
const direction = (document.getElementById("order-direction")||{}).value || "long";
const mode = (document.getElementById("sltp-mode")||{}).value || "price";
const symbol = ((document.getElementById("order-symbol")||{}).value || "").trim();
if(mode === "pct"){
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
const rr = calcClientRrFromPct(
(document.getElementById("order-sl-pct")||{}).value,
(document.getElementById("order-tp-pct")||{}).value
);
if(rejectManualOrderRr(rr)) return;
if(rejectManualOrderRr(rr)){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
allowManualOrderSubmit(addOrderForm);
return;
}
@@ -2010,6 +2028,7 @@ if(addOrderForm){
alert("请先填写币种,以便按市价校验盈亏比");
return;
}
if(window.FormSubmitGuard) FormSubmitGuard.lock(addOrderForm, "校验盈亏比…");
fetch(`/api/order_defaults?symbol=${encodeURIComponent(symbol)}&direction=${encodeURIComponent(direction)}`)
.then(r=>r.json())
.then(data=>{
@@ -2017,12 +2036,19 @@ if(addOrderForm){
const entry = Number(px);
if(!Number.isFinite(entry) || entry <= 0){
alert("无法获取市价,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))){
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
return;
}
if(rejectManualOrderRr(calcClientRr(direction, entry, sl, tp))) return;
allowManualOrderSubmit(addOrderForm);
})
.catch(()=>{ alert("无法校验盈亏比,请稍后重试"); });
.catch(()=>{
alert("无法校验盈亏比,请稍后重试");
if(window.FormSubmitGuard) FormSubmitGuard.unlock(addOrderForm);
});
});
}
+51
View File
@@ -0,0 +1,51 @@
"""防重复提交:Flask session 短窗口去重(下单 / 关键位等)。"""
from __future__ import annotations
import time
from typing import Any, Optional
DEFAULT_SUBMIT_GUARD_TTL = 90.0
def _prune_locks(locks: dict, now: float) -> dict:
return {k: float(v) for k, v in (locks or {}).items() if float(v) > now}
def check_duplicate_submit(
session: Any,
scope: str,
*,
ttl: float = DEFAULT_SUBMIT_GUARD_TTL,
) -> Optional[str]:
"""
同一 scope ttl 秒内仅允许通过一次
返回提示文案表示应拒绝返回 None 表示可继续处理
"""
scope = (scope or "").strip()
if not scope:
return None
now = time.time()
locks = _prune_locks(session.get("_form_submit_guard") or {}, now)
if scope in locks:
return "请求正在处理或刚提交过,请勿重复点击(请等待页面刷新后再试)"
locks[scope] = now + float(ttl)
session["_form_submit_guard"] = locks
try:
session.modified = True
except Exception:
pass
return None
def submit_scope_add_order(symbol: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
d = (direction or "").strip().lower()
return f"add_order:{sym}:{d}"
def submit_scope_add_key(symbol: str, monitor_type: str, direction: str) -> str:
sym = (symbol or "").strip().upper()
mt = (monitor_type or "").strip()
d = (direction or "").strip().lower() or "watch"
return f"add_key:{sym}:{mt}:{d}"
+65
View File
@@ -0,0 +1,65 @@
/**
* 表单提交防重复网络慢时禁用按钮并显示提交中
*/
(function (global) {
"use strict";
function submitButtons(form) {
if (!form) return [];
return Array.prototype.slice.call(
form.querySelectorAll('button[type="submit"], input[type="submit"]')
);
}
function lockForm(form, label) {
if (!form) return false;
if (form.dataset.submitGuard === "locked") return false;
form.dataset.submitGuard = "locked";
form.classList.add("is-form-submitting");
submitButtons(form).forEach(function (btn) {
if (btn.dataset.submitGuardOrig === undefined) {
btn.dataset.submitGuardOrig =
btn.tagName === "BUTTON" ? btn.textContent : btn.value;
}
btn.disabled = true;
if (label) {
if (btn.tagName === "BUTTON") btn.textContent = label;
else btn.value = label;
}
});
return true;
}
function unlockForm(form) {
if (!form) return;
delete form.dataset.submitGuard;
form.classList.remove("is-form-submitting");
submitButtons(form).forEach(function (btn) {
btn.disabled = false;
var orig = btn.dataset.submitGuardOrig;
if (orig !== undefined) {
if (btn.tagName === "BUTTON") btn.textContent = orig;
else btn.value = orig;
delete btn.dataset.submitGuardOrig;
}
});
}
function isLocked(form) {
return !!(form && form.dataset.submitGuard === "locked");
}
/** 已通过前端校验,发起最终 POST(页面将跳转) */
function nativeSubmitOnce(form, label) {
if (!form) return;
lockForm(form, label || "提交中…");
form.submit();
}
global.FormSubmitGuard = {
lock: lockForm,
unlock: unlockForm,
isLocked: isLocked,
nativeSubmitOnce: nativeSubmitOnce,
};
})(typeof window !== "undefined" ? window : this);