修复前端

This commit is contained in:
dekun
2026-05-22 11:29:34 +08:00
parent 371dec6999
commit 427f94e0e8
4 changed files with 246 additions and 114 deletions
+123 -97
View File
@@ -6343,112 +6343,138 @@ def api_key_kline():
@app.route("/add_key", methods=["POST"]) @app.route("/add_key", methods=["POST"])
@login_required @login_required
def add_key(): def add_key():
d = request.form conn = None
symbol = normalize_symbol_input(d.get("symbol")) try:
if not symbol: d = request.form
flash("symbol 不能为空") symbol = normalize_symbol_input(d.get("symbol"))
return redirect("/key_monitor") if not symbol:
direction_sel = (d.get("direction") or "").strip().lower() flash("symbol 不能为空")
if direction_sel not in ("long", "short"): return redirect("/key_monitor")
flash("请选择做多或做空") direction_sel = (d.get("direction") or "").strip().lower()
return redirect("/key_monitor") if direction_sel not in ("long", "short"):
mt = (d.get("type") or "").strip() flash("请选择做多或做空")
allowed_types = ( return redirect("/key_monitor")
tuple(KEY_MONITOR_AUTO_TYPES) mt = (d.get("type") or "").strip()
+ tuple(KEY_MONITOR_ALERT_ONLY_TYPES) allowed_types = (
+ tuple(FIB_KEY_MONITOR_TYPES) tuple(KEY_MONITOR_AUTO_TYPES)
) + tuple(KEY_MONITOR_ALERT_ONLY_TYPES)
if mt not in allowed_types: + tuple(FIB_KEY_MONITOR_TYPES)
flash("监控类型无效") )
return redirect("/key_monitor") if mt not in allowed_types:
rank, total = _daily_volume_rank(symbol) flash("监控类型无效")
if rank is None: return redirect("/key_monitor")
flash("日成交量排名读取失败,请稍后重试") rank, total = _daily_volume_rank(symbol)
return redirect("/key_monitor") if rank is None:
if rank > KEY_DAILY_VOLUME_RANK_MAX: flash("日成交量排名读取失败,请稍后重试")
flash(f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位") return redirect("/key_monitor")
return redirect("/key_monitor") if rank > KEY_DAILY_VOLUME_RANK_MAX:
conn = get_db()
if mt in KEY_MONITOR_AUTO_TYPES:
occupied = get_active_position_count(conn)
if occupied >= MAX_ACTIVE_POSITIONS:
conn.close()
flash( flash(
f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。" f"{symbol} 当前日成交量排名为 {rank}/{total},不在前{KEY_DAILY_VOLUME_RANK_MAX},已拒绝添加关键位"
"请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
) )
return redirect("/key_monitor") return redirect("/key_monitor")
ex_sym_key = normalize_exchange_symbol(symbol) conn = get_db()
try: if mt in KEY_MONITOR_AUTO_TYPES:
ensure_markets_loaded() occupied = get_active_position_count(conn)
except Exception: if occupied >= MAX_ACTIVE_POSITIONS:
pass conn.close()
upper_px = round_price_to_exchange(ex_sym_key, float(d["upper"])) conn = None
lower_px = round_price_to_exchange(ex_sym_key, float(d["lower"])) flash(
be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled")) f"当前持仓已达上限({occupied}/{MAX_ACTIVE_POSITIONS}):无法添加「箱体突破 / 收敛突破」。"
if is_fib_key_monitor_type(mt): "请平仓后再试,或使用「关键阻力位/关键支撑位」(仅单次提醒)。"
ok_fib, err_fib = _add_fib_key_monitor( )
conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag, return redirect("/key_monitor")
ex_sym_key = normalize_exchange_symbol(symbol)
try:
ensure_markets_loaded()
except Exception:
pass
try:
upper_raw = float(d.get("upper") or 0)
lower_raw = float(d.get("lower") or 0)
except (TypeError, ValueError):
conn.close()
conn = None
flash("上下沿须为有效数字")
return redirect("/key_monitor")
upper_px = round_price_to_exchange(ex_sym_key, upper_raw)
lower_px = round_price_to_exchange(ex_sym_key, lower_raw)
be_flag = parse_breakeven_enabled_form(d.get("breakeven_enabled"))
if is_fib_key_monitor_type(mt):
ok_fib, err_fib = _add_fib_key_monitor(
conn, symbol, direction_sel, mt, upper_px, lower_px, breakeven_enabled=be_flag,
)
conn.commit()
conn.close()
conn = None
if not ok_fib:
flash(err_fib or "斐波监控添加失败")
return redirect("/key_monitor")
flash(
f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total}"
f"|移动保本:{'' if be_flag else ''}"
)
return redirect("/key_monitor")
sl_tp_mode = "standard"
manual_tp = None
if mt in KEY_MONITOR_AUTO_TYPES:
sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode"))
if sl_tp_mode == "trend_manual":
try:
manual_tp = float(d.get("manual_take_profit") or 0)
except (TypeError, ValueError):
manual_tp = 0
if manual_tp <= 0:
conn.close()
conn = None
flash("趋势单方案须填写有效止盈价")
return redirect("/key_monitor")
if direction_sel == "long" and manual_tp <= upper_px:
conn.close()
conn = None
flash("做多趋势单:止盈价应高于上沿(阻力)")
return redirect("/key_monitor")
if direction_sel == "short" and manual_tp >= lower_px:
conn.close()
conn = None
flash("做空趋势单:止盈价应低于下沿(支撑)")
return redirect("/key_monitor")
mtpx = round_price_to_exchange(ex_sym_key, manual_tp)
if mtpx is not None:
manual_tp = float(mtpx)
conn.execute(
"INSERT INTO key_monitors "
"(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) "
"VALUES (?,?,?,?,?,?,?,?)",
(symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag),
) )
conn.commit() conn.commit()
conn.close() conn.close()
if not ok_fib: conn = None
flash(err_fib or "斐波监控添加失败") ctr = False
return redirect("/key_monitor") try:
flash( coin4h_status, _, _ = _status_by_ema55(symbol, "4h")
f"斐波监控已添加,限价单已挂出({symbol} 日成交量排名 {rank}/{total}" ctr = (direction_sel == "long" and coin4h_status == "空头") or (
f"|移动保本:{'' if be_flag else ''}" direction_sel == "short" and coin4h_status == "多头"
) )
except Exception:
pass
extra = ""
if mt in KEY_MONITOR_AUTO_TYPES:
extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'' if be_flag else ''}"
flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}{extra}")
if ctr:
flash(
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
)
return redirect("/key_monitor") return redirect("/key_monitor")
sl_tp_mode = "standard" except Exception as e:
manual_tp = None if conn is not None:
if mt in KEY_MONITOR_AUTO_TYPES:
sl_tp_mode = normalize_sl_tp_mode(d.get("sl_tp_mode"))
if sl_tp_mode == "trend_manual":
try: try:
manual_tp = float(d.get("manual_take_profit") or 0)
except (TypeError, ValueError):
manual_tp = 0
if manual_tp <= 0:
conn.close() conn.close()
flash("趋势单方案须填写有效止盈价") except Exception:
return redirect("/key_monitor") pass
if direction_sel == "long" and manual_tp <= upper_px: flash(f"添加关键位失败:{e}")
conn.close() return redirect("/key_monitor")
flash("做多趋势单:止盈价应高于上沿(阻力)")
return redirect("/key_monitor")
if direction_sel == "short" and manual_tp >= lower_px:
conn.close()
flash("做空趋势单:止盈价应低于下沿(支撑)")
return redirect("/key_monitor")
mtpx = round_price_to_exchange(ex_sym_key, manual_tp)
if mtpx is not None:
manual_tp = float(mtpx)
conn.execute(
"INSERT INTO key_monitors "
"(symbol,monitor_type,direction,upper,lower,sl_tp_mode,manual_take_profit,breakeven_enabled) "
"VALUES (?,?,?,?,?,?,?,?)",
(symbol, mt, direction_sel, upper_px, lower_px, sl_tp_mode, manual_tp, be_flag),
)
conn.commit()
conn.close()
ctr = False
try:
coin4h_status, _, _ = _status_by_ema55(symbol, "4h")
ctr = (direction_sel == "long" and coin4h_status == "空头") or (
direction_sel == "short" and coin4h_status == "多头"
)
except Exception:
pass
extra = ""
if mt in KEY_MONITOR_AUTO_TYPES:
extra = f"|方案:{sl_tp_mode_label(sl_tp_mode)}|移动保本:{'' if be_flag else ''}"
flash(f"添加成功({symbol} 日成交量排名 {rank}/{total}{extra}")
if ctr:
flash(
"⚠️ 4h EMA55 提示:当前与所选方向逆势;「箱体突破/收敛突破」在条件满足时仍会按计划自动市价开仓,请注意仓位。"
)
return redirect("/key_monitor")
@app.route("/add_order", methods=["POST"]) @app.route("/add_order", methods=["POST"])
@login_required @login_required
+32 -4
View File
@@ -38,27 +38,55 @@ def _row_to_dict(row):
return dict(row) if row is not None else {} return dict(row) if row is not None else {}
_FAIL_HINTS = (
"失败",
"错误",
"拒绝",
"无效",
"缺少",
"无法",
"过期",
"未达",
"不能为空",
"已有",
"不允许",
"异常",
)
def _invoke_view(view_name: str, path: str, form=None) -> dict: def _invoke_view(view_name: str, path: str, form=None) -> dict:
views = _ctx().get("views") or {} views = _ctx().get("views") or {}
view = views.get(view_name) view = views.get(view_name)
if not view: if not view:
return {"ok": False, "messages": [f"未配置视图 {view_name}"]} return {"ok": False, "messages": [f"未配置视图 {view_name}"]}
data = form if form is not None else request.form data = form if form is not None else request.form
if hasattr(data, "items") and not isinstance(data, dict):
data = {k: v for k, v in data.items()}
with current_app.test_request_context(path, method="POST", data=data): with current_app.test_request_context(path, method="POST", data=data):
session["logged_in"] = True session["logged_in"] = True
try: try:
view() view()
except Exception as e: except Exception as e:
return {"ok": False, "messages": [str(e)]} return {"ok": False, "messages": [str(e)]}
msgs = [str(x) for x in get_flashed_messages()] try:
msgs = [str(x) for x in get_flashed_messages()]
except Exception as e:
return {"ok": False, "messages": [f"读取提示信息失败: {e}"]}
ok = True ok = True
for m in msgs: for m in msgs:
if any(k in m for k in ("失败", "错误", "拒绝", "无效", "缺少", "无法", "过期")): if any(k in m for k in _FAIL_HINTS):
ok = False ok = False
break break
return {"ok": ok, "messages": msgs} return {"ok": ok, "messages": msgs}
def _hub_json(view_name: str, path: str, form=None):
try:
return jsonify(_invoke_view(view_name, path, form=form))
except Exception as e:
return jsonify({"ok": False, "messages": [str(e)]})
def install_on_app( def install_on_app(
app, app,
*, *,
@@ -155,12 +183,12 @@ def register_hub_routes(app):
@app.route("/api/hub/add_order", methods=["POST"]) @app.route("/api/hub/add_order", methods=["POST"])
@_hub_auth_required @_hub_auth_required
def api_hub_add_order(): def api_hub_add_order():
return jsonify(_invoke_view("add_order", "/trade")) return _hub_json("add_order", "/add_order")
@app.route("/api/hub/add_key", methods=["POST"]) @app.route("/api/hub/add_key", methods=["POST"])
@_hub_auth_required @_hub_auth_required
def api_hub_add_key(): def api_hub_add_key():
return jsonify(_invoke_view("add_key", "/key_monitor")) return _hub_json("add_key", "/add_key")
@app.route("/api/hub/trend/preview", methods=["POST"]) @app.route("/api/hub/trend/preview", methods=["POST"])
@_hub_auth_required @_hub_auth_required
+69 -10
View File
@@ -182,6 +182,46 @@ async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict:
} }
def _exchange_brief(ex: dict | None) -> dict | None:
if not ex:
return None
return {
"id": ex.get("id"),
"key": ex.get("key"),
"name": ex.get("name"),
}
def _form_plain_dict(form) -> dict[str, str]:
out: dict[str, str] = {}
for k, v in form.multi_items() if hasattr(form, "multi_items") else form.items():
if hasattr(v, "read"):
continue
out[str(k)] = "" if v is None else str(v)
return out
def _parse_http_json_body(r: httpx.Response) -> dict:
text = (r.text or "").strip()
if not text:
return {"ok": False, "status": r.status_code, "text": "(empty body)"}
try:
data = r.json()
if isinstance(data, dict):
return data
return {"ok": False, "status": r.status_code, "text": text[:500]}
except Exception:
snippet = text[:500]
if snippet.lstrip().lower().startswith("<!") or "internal server error" in snippet.lower():
return {
"ok": False,
"status": r.status_code,
"messages": [f"实例返回 HTML 错误(HTTP {r.status_code}),请查看该 Flask 日志"],
"text": snippet,
}
return {"ok": False, "status": r.status_code, "messages": [snippet], "text": snippet}
async def _fetch_flask_json( async def _fetch_flask_json(
client: httpx.AsyncClient, ex: dict, path: str, method: str = "GET", data=None client: httpx.AsyncClient, ex: dict, path: str, method: str = "GET", data=None
) -> dict | None: ) -> dict | None:
@@ -194,8 +234,11 @@ async def _fetch_flask_json(
else: else:
r = await client.post(f"{base}{path}", headers=_hub_headers(), data=data, timeout=120.0) r = await client.post(f"{base}{path}", headers=_hub_headers(), data=data, timeout=120.0)
if r.status_code >= 400: if r.status_code >= 400:
return {"ok": False, "status": r.status_code, "text": (r.text or "")[:500]} parsed = _parse_http_json_body(r)
return r.json() parsed.setdefault("ok", False)
parsed.setdefault("status", r.status_code)
return parsed
return _parse_http_json_body(r)
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} return {"ok": False, "error": str(e)}
@@ -308,10 +351,18 @@ async def api_trade_order(exchange_id: str, request: Request):
ex = _find_exchange(exchange_id) ex = _find_exchange(exchange_id)
if not ex or not ex.get("enabled"): if not ex or not ex.get("enabled"):
raise HTTPException(status_code=404, detail="账户未启用") raise HTTPException(status_code=404, detail="账户未启用")
form = await request.form() try:
async with httpx.AsyncClient() as client: form = _form_plain_dict(await request.form())
result = await _fetch_flask_json(client, ex, "/api/hub/add_order", "POST", dict(form)) async with httpx.AsyncClient() as client:
return {"exchange": ex, "result": result} result = await _fetch_flask_json(client, ex, "/api/hub/add_order", "POST", form)
return {"exchange": _exchange_brief(ex), "result": result}
except HTTPException:
raise
except Exception as e:
return JSONResponse(
{"exchange": _exchange_brief(ex), "result": {"ok": False, "messages": [str(e)]}},
status_code=200,
)
@app.post("/api/trade/key/{exchange_id}") @app.post("/api/trade/key/{exchange_id}")
@@ -321,10 +372,18 @@ async def api_trade_key(exchange_id: str, request: Request):
raise HTTPException(status_code=404, detail="账户未启用") raise HTTPException(status_code=404, detail="账户未启用")
if "key" not in (ex.get("capabilities") or []): if "key" not in (ex.get("capabilities") or []):
raise HTTPException(status_code=400, detail="该账户不支持关键位") raise HTTPException(status_code=400, detail="该账户不支持关键位")
form = await request.form() try:
async with httpx.AsyncClient() as client: form = _form_plain_dict(await request.form())
result = await _fetch_flask_json(client, ex, "/api/hub/add_key", "POST", dict(form)) async with httpx.AsyncClient() as client:
return {"exchange": ex, "result": result} result = await _fetch_flask_json(client, ex, "/api/hub/add_key", "POST", form)
return {"exchange": _exchange_brief(ex), "result": result}
except HTTPException:
raise
except Exception as e:
return JSONResponse(
{"exchange": _exchange_brief(ex), "result": {"ok": False, "messages": [str(e)]}},
status_code=200,
)
@app.post("/api/trade/trend/preview/{exchange_id}") @app.post("/api/trade/trend/preview/{exchange_id}")
+22 -3
View File
@@ -294,15 +294,34 @@
} }
} }
async function parseJsonResponse(r) {
const text = await r.text();
if (!text) return {};
try {
return JSON.parse(text);
} catch (e) {
const snippet = text.slice(0, 200);
throw new Error(
`HTTP ${r.status} 响应不是 JSON${snippet}${text.length > 200 ? "…" : ""}`
);
}
}
async function submitForm(path, formEl) { async function submitForm(path, formEl) {
const id = document.getElementById("trade-account").value; const id = document.getElementById("trade-account").value;
const fd = new FormData(formEl); const fd = new FormData(formEl);
try { try {
const r = await fetch(path + encodeURIComponent(id), { method: "POST", body: fd }); const r = await fetch(path + encodeURIComponent(id), { method: "POST", body: fd });
const j = await r.json(); const j = await parseJsonResponse(r);
const res = j.result || {}; const res = j.result || {};
const msgs = (res.messages || []).join("\n") || JSON.stringify(res, null, 2); const msgs =
showToast(msgs, !res.ok); (res.messages || []).join("\n") ||
res.error ||
res.msg ||
res.text ||
JSON.stringify(res, null, 2);
const failed = res.ok === false || r.status >= 400 || !!res.error || !!res.text;
showToast(msgs || (failed ? "操作失败" : "已提交"), failed);
if (res.ok && res.preview) { if (res.ok && res.preview) {
showTrendPreview(res); showTrendPreview(res);
} }