diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index ea81721..faa9a1c 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -6828,7 +6828,7 @@ def fetch_binance_net_pnl_for_trade( # ====================== 主页面 ====================== -def render_main_page(page="trade"): +def render_main_page(page="trade", embed_mode=None): now = app_now() trading_day = get_trading_day(now) list_window = _list_window_from_request() @@ -6909,8 +6909,9 @@ def render_main_page(page="trade"): if not order_list and exchange_private_api_configured(): orphan_live_positions = list_orphan_live_positions(conn) conn.close() - return render_template( - "index.html", + from instance_embed_lib import embed_context_extras + + template_ctx = dict( page=page, key=key_list, key_history=key_history, @@ -6979,7 +6980,13 @@ def render_main_page(page="trade"): key_rule_ctx=key_rule_ctx, kline_timeframe=KLINE_TIMEFRAME, **strategy_extra, + **embed_context_extras("binance"), ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template("embed_shell.html", initial_tab=page, **template_ctx) + return render_template("index.html", **template_ctx) @app.route("/") @@ -9375,6 +9382,8 @@ try: market_fn=_hub_fetch_market, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, ) except Exception as _hub_err: print(f"[hub_bridge] binance: {_hub_err}") diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index d16500f..6e8567f 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -6702,7 +6702,7 @@ def sync_trade_records_from_exchange(conn, force=False): # ====================== 主页面 ====================== -def render_main_page(page="trade"): +def render_main_page(page="trade", embed_mode=None): now = app_now() trading_day = get_trading_day(now) list_window = _list_window_from_request() @@ -6726,7 +6726,10 @@ def render_main_page(page="trade"): for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) exchange_pnl_sync = {} - if exchange_private_api_configured() and not request_is_hub_soft_nav(): + if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( + "fragment", + "shell", + ): try: exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} except Exception as e: @@ -6786,8 +6789,9 @@ def render_main_page(page="trade"): trend_cfg=app.extensions.get("strategy_trend_cfg"), ) conn.close() - return render_template( - "index.html", + from instance_embed_lib import embed_context_extras + + template_ctx = dict( page=page, key=key_list, key_history=key_history, @@ -6859,7 +6863,17 @@ def render_main_page(page="trade"): kline_timeframe=KLINE_TIMEFRAME, exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, + **embed_context_extras("gate"), ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template( + "embed_shell.html", + initial_tab=page, + **template_ctx, + ) + return render_template("index.html", **template_ctx) @app.route("/api/sync_exchange_pnl") @@ -9321,6 +9335,8 @@ try: reconcile_hub_flat_fn=reconcile_hub_external_close, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, ) except Exception as _hub_err: print(f"[hub_bridge] gate: {_hub_err}") diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 500bb15..1ef086d 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -6702,7 +6702,7 @@ def sync_trade_records_from_exchange(conn, force=False): # ====================== 主页面 ====================== -def render_main_page(page="trade"): +def render_main_page(page="trade", embed_mode=None): now = app_now() trading_day = get_trading_day(now) list_window = _list_window_from_request() @@ -6726,7 +6726,10 @@ def render_main_page(page="trade"): for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) exchange_pnl_sync = {} - if exchange_private_api_configured() and not request_is_hub_soft_nav(): + if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( + "fragment", + "shell", + ): try: exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} except Exception as e: @@ -6786,8 +6789,9 @@ def render_main_page(page="trade"): trend_cfg=app.extensions.get("strategy_trend_cfg"), ) conn.close() - return render_template( - "index.html", + from instance_embed_lib import embed_context_extras + + template_ctx = dict( page=page, key=key_list, key_history=key_history, @@ -6859,7 +6863,13 @@ def render_main_page(page="trade"): kline_timeframe=KLINE_TIMEFRAME, exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, + **embed_context_extras("gate_bot"), ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template("embed_shell.html", initial_tab=page, **template_ctx) + return render_template("index.html", **template_ctx) @app.route("/api/sync_exchange_pnl") @@ -9321,6 +9331,8 @@ try: reconcile_hub_flat_fn=reconcile_hub_external_close, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, ) except Exception as _hub_err: print(f"[hub_bridge] gate_bot: {_hub_err}") diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 9c7219c..a621e56 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6207,7 +6207,7 @@ def api_sync_positions(): # ====================== 主页面 ====================== -def render_main_page(page="trade"): +def render_main_page(page="trade", embed_mode=None): now = app_now() trading_day = get_trading_day(now) list_window = _list_window_from_request() @@ -6230,7 +6230,10 @@ def render_main_page(page="trade"): for o in raw_order_list: order_list.append(enrich_order_item(row_to_dict(o), current_capital)) exchange_pnl_sync = {} - if exchange_private_api_configured() and not request_is_hub_soft_nav(): + if exchange_private_api_configured() and not request_is_hub_soft_nav() and embed_mode not in ( + "fragment", + "shell", + ): try: exchange_pnl_sync = sync_trade_records_from_exchange(conn) or {} except Exception as e: @@ -6292,8 +6295,9 @@ def render_main_page(page="trade"): trend_cfg=app.extensions.get("strategy_trend_cfg"), ) conn.close() - return render_template( - "index.html", + from instance_embed_lib import embed_context_extras + + template_ctx = dict( page=page, key=key_list, key_history=key_history, @@ -6364,7 +6368,13 @@ def render_main_page(page="trade"): funding_usdt=funding_usdt, exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, + **embed_context_extras("okx"), ) + if embed_mode == "fragment": + return render_template("embed_page_fragment.html", **template_ctx) + if embed_mode == "shell": + return render_template("embed_shell.html", initial_tab=page, **template_ctx) + return render_template("index.html", **template_ctx) @app.route("/api/sync_exchange_pnl") @@ -8788,6 +8798,8 @@ try: market_fn=_hub_fetch_market, risk_status_fn=hub_account_risk_status, user_close_fn=hub_user_initiated_close, + render_main_page_fn=render_main_page, + login_required_fn=login_required, ) except Exception as _hub_err: print(f"[hub_bridge] okx: {_hub_err}") diff --git a/embed_templates/embed_boot_scripts.html b/embed_templates/embed_boot_scripts.html new file mode 100644 index 0000000..8e83b57 --- /dev/null +++ b/embed_templates/embed_boot_scripts.html @@ -0,0 +1,1338 @@ + diff --git a/embed_templates/embed_page_fragment.html b/embed_templates/embed_page_fragment.html new file mode 100644 index 0000000..efdea98 --- /dev/null +++ b/embed_templates/embed_page_fragment.html @@ -0,0 +1,464 @@ +{# Hub iframe tab fragment — shared via embed_templates #} +{% macro period_stats(title, s) %} +
+

{{ title }}

+
{{ s.range_label }}
+
+
开单次数
{{ s.opens_count }}
+
平仓笔数
{{ s.closed_count }}
+
胜率
{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}
+
净盈亏(U)
{{ funds_fmt(s.net_pnl_u) }}
+
亏损额合计(U)
{{ funds_fmt(s.loss_sum_u) }}
+
单笔最大亏损(U)
{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}
+
单笔最大盈利(U)
{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}
+
最大回撤(U)
{{ funds_fmt(s.max_drawdown_u) }}
+
当前连续亏损笔数
{{ s.consecutive_losses }}
+
最长连续亏损(交易日)
{{ s.max_loss_streak_days }} 天
+
期内最大亏损日
{% if s.worst_day %}{{ s.worst_day }}({{ funds_fmt(s.worst_day_pnl) }}U){% else %}-{% endif %}
+
+
+{% endmacro %} +
+ {% if page == 'key_monitor' %} + {% include 'key_monitor_panel.html' %} + {% elif page == 'trade' %} +
+
+
+

实盘下单监控

+ {% if focus_order_id %} + 放大查看K线(100根) + {% else %} + 暂无持仓可放大 + {% endif %} +
+ {% include order_rule_tips_tpl %} +
+ + + + + {% if position_sizing_mode != 'full_margin' %} + + {% endif %} + + + + + + + 成交价自动取交易所实时+成交回报 + + + + + + + + +
+
+
+

实时持仓

+
+ {% for o in order %} +
+
+
+ {{ o.exchange_symbol or o.symbol }} + {% if o.time_close_enabled %} + + 时间平仓 {{ o.time_close_hours or '' }}h + · --:--:-- + + {% endif %} + {{ '做多' if o.direction == 'long' else '做空' }} +
+
+ + 平仓 +
+
+
+ 来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %} + 风格: {{ o.trade_style or 'trend' }} + 风险: {% if position_sizing_mode == 'full_margin' %}{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% else %}{{ o.risk_percent or '-' }}%≈{{ funds_fmt(o.risk_amount) if o.risk_amount is not none else '-' }}U{% endif %} + + {% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %} + + +
+
+
+ 成交价 + {{ price_fmt(o.symbol, o.trigger_price) }} +
+
+ 止损 + {{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }} +
+
+ 止盈 + {{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }} +
+
+ 盈亏比 + {% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %} +
+
+ 标记价 + - +
+
+ 浮盈亏 + - +
+
+ +
+
交易所止盈止损
+
+ 止损:加载中… + +
+
+ 止盈:加载中… + +
+
+
+ {% else %} +
暂无持仓
+ {% endfor %} +
+
+ +
+
+

挂止盈止损

+

将先撤销该合约已有 TP/SL,再按下列价格重挂。

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {% elif page in ('strategy', 'strategy_trend', 'strategy_roll') %} + {% include 'strategy_trading_page.html' %} + {% elif page == 'strategy_records' %} + {% include 'strategy_records_page.html' %} + {% endif %} + + + + {% if page == 'records' %} +
+

交易记录 & 错过机会

+
+ +
+
+ + + {% for r in record %} + + {% set pnl_val = (r.pnl_amount or 0)|float %} + + + + + {% set stop_show = r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss %} + {% set tp_show = r.effective_take_profit or r.take_profit %} + + + + + + + + {% set pnl_val = (r.effective_pnl_amount or 0)|float %} + + + + + {% endfor %} +
品种类型方向成交止损(开仓)止盈基数杠杆持仓分钟开仓时间(北京)平仓时间(北京)盈亏U结果操作
{{ r.symbol }}{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}{{ '做多' if r.direction == 'long' else '做空' }}{{ price_fmt(r.symbol, r.trigger_price) }}{{ price_fmt(r.symbol, stop_show) }}{{ price_fmt(r.symbol, tp_show) }}{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}{{ r.leverage or '-' }}{{ r.effective_hold_minutes or 0 }}{{ (r.effective_opened_at or '-')[:16] }}{{ (r.effective_closed_at or r.created_at or '-')[:16] }}{{ funds_fmt(r.effective_pnl_amount or 0) }}{% if r.display_pnl_source == 'exchange' %}{% elif r.display_pnl_source != 'reviewed' %}{% endif %} + {% set effective_result = r.effective_result %} + {% if effective_result in ["止盈","保本止盈","移动止盈"] %}{{ effective_result }} + {% elif effective_result in ["止损","强制清仓","手动平仓"] %}{{ effective_result }} + {% elif effective_result == "时间平仓" %}{{ effective_result }} + {% else %}{{ effective_result }}{% endif %} + + + + +
+
+
+ +
+

记录错过机会

+
+ + + + + + + + +
+
+ +
+

交易复盘记录上传(含截图)

+
+ + + + + +
+ + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+
双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位
+
+ +
+
+ + + + + + +
+ + +
+
+ +
+
+

AI复盘(按交易记录)

+ +
+
+ + + + + + + +
+ + +
+
+ 交易复盘记录 +
+
+
+ AI历史复盘 +
+
+
+
+
+ + {% endif %} + + {% if page == 'stats' %} +
+
+

数据统计

+ +
+
+
+
持仓占用导致错过(累计)
{{ occupied_miss_total }}
+
+
+ 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计): + {{ stats_bundle.total_opens_all }} 次 +
+
+ +
+ {% for seg in stats_bundle.segments %} + + {% endfor %} +
+
+ {% endif %} diff --git a/embed_templates/embed_shell.html b/embed_templates/embed_shell.html new file mode 100644 index 0000000..c7f700a --- /dev/null +++ b/embed_templates/embed_shell.html @@ -0,0 +1,120 @@ + + + + + + + + + + + + + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 + + +
+
+

加密货币|交易监控 + AI复盘一体化

+
+
{{ exchange_display }}
+ {{ risk_status.status_label|default('正常') }} +
+ + +
+
+
+ + + +
+ 列表筛选(UTC,默认当日):{{ list_window.label }} + + + + + + + 统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日 +
+
+ 数据导出(v{{ data_export_version }} CSV,UTF-8;交易记录含开仓类型列,复盘单独导出): + 交易记录 + 复盘记录 + 关键位(当前) + 关键位历史 +
+
+
交易所
{{ exchange_display }}
+
总交易
{{ total }}
+
错过次数
{{ miss_count }}
+
胜率
{{ rate }}%
+
资金账户(USDT)
{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}
+
交易日
{{ trading_day }}
+
当日资金(交易账户)
{{ funds_fmt(current_capital) }}U
+
+ {% if include_transfer_block %} + {% include 'gate_transfer_block.html' %} + {% endif %} + +
+ {% include 'embed_page_fragment.html' %} +
+
+ + +
+
+
+
详情
+
+ + +
+
+
+ +
+
+ + + + + + + + +{% include 'embed_boot_scripts.html' %} + + + diff --git a/hub_bridge.py b/hub_bridge.py index 0616c79..07433a2 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -59,6 +59,9 @@ def install_instance_theme_static(app) -> None: "form_submit_guard.js": "application/javascript; charset=utf-8", "key_monitor_form.js": "application/javascript; charset=utf-8", "time_close_ui.js": "application/javascript; charset=utf-8", + "manual_order_rr_preview.js": "application/javascript; charset=utf-8", + "instance_page.css": "text/css; charset=utf-8", + "instance_embed.js": "application/javascript; charset=utf-8", "focus_chart_page.js": "application/javascript; charset=utf-8", "focus_chart_page.css": "text/css; charset=utf-8", } @@ -201,6 +204,19 @@ def _hub_json(view_name: str, path: str, form=None): return jsonify({"ok": False, "messages": [str(e)]}) +def _embed_login_dest(next_path: str) -> str: + """embed=1 时把 /trade 等映射到 /embed?tab=…""" + ht = (request.args.get("hub_theme") or "").strip().lower() + hub_theme = ht if ht in ("light", "dark") else None + if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"): + from instance_embed_lib import rewrite_embed_dest + + return rewrite_embed_dest(next_path, hub_theme=hub_theme) + if hub_theme: + return _merge_query_into_path(next_path, hub_theme=hub_theme) + return next_path + + def install_on_app( app, *, @@ -218,6 +234,8 @@ def install_on_app( reconcile_hub_flat_fn=None, risk_status_fn=None, user_close_fn=None, + render_main_page_fn=None, + login_required_fn=None, ): app.config["HUB_CTX"] = { "exchange": exchange, @@ -239,6 +257,14 @@ def install_on_app( configure_hub_embed_session(app) install_instance_theme_static(app) register_hub_routes(app) + if render_main_page_fn and login_required_fn: + import os + + from instance_embed_lib import attach_embed_templates, register_embed_routes + + repo_root = os.path.dirname(os.path.abspath(__file__)) + attach_embed_templates(app, repo_root) + register_embed_routes(app, login_required_fn, render_main_page_fn) def configure_hub_embed_session(app): @@ -797,25 +823,22 @@ def register_hub_routes(app): token = (request.args.get("token") or "").strip() ok, next_path, err = verify_hub_sso_token(token, ex) if ok: + dest_next = _embed_login_dest(next_path) if request.args.get( + "embed", "" + ).strip().lower() in ("1", "true", "yes", "on") else next_path if _sso_wants_embed_auth() and request.is_secure: - boot = mint_hub_embed_bootstrap(ex, next_path) + boot = mint_hub_embed_bootstrap(ex, dest_next) if boot: from urllib.parse import urlencode as _ue - qdict = {"t": boot, "next": next_path, "embed": "1"} + qdict = {"t": boot, "next": dest_next, "embed": "1"} ht0 = (request.args.get("hub_theme") or "").strip().lower() if ht0 in ("light", "dark"): qdict["hub_theme"] = ht0 return redirect(f"/hub-embed-auth?{_ue(qdict)}") session["logged_in"] = True session.modified = True - dest = next_path - if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"): - dest = _merge_query_into_path(dest, embed="1") - ht = (request.args.get("hub_theme") or "").strip().lower() - if ht in ("light", "dark"): - dest = _merge_query_into_path(dest, hub_theme=ht) - return redirect(dest) + return redirect(_embed_login_dest(next_path)) hint = err or "校验失败" flash( f"中控 SSO 未生效({hint})。" @@ -839,13 +862,7 @@ def register_hub_routes(app): if ok: session["logged_in"] = True session.modified = True - dest = next_path - if request.args.get("embed", "").strip().lower() in ("1", "true", "yes", "on"): - dest = _merge_query_into_path(dest, embed="1") - ht = (request.args.get("hub_theme") or "").strip().lower() - if ht in ("light", "dark"): - dest = _merge_query_into_path(dest, hub_theme=ht) - return redirect(dest) + return redirect(_embed_login_dest(next_path)) hint = err or "校验失败" flash(f"iframe 登录未生效({hint})。可点本地导航工具栏「实例免密」重试。") return redirect("/login") diff --git a/instance_embed_lib.py b/instance_embed_lib.py new file mode 100644 index 0000000..66c6174 --- /dev/null +++ b/instance_embed_lib.py @@ -0,0 +1,132 @@ +"""中控 iframe:壳常驻 + tab 内容 API(/embed、/api/embed/page/)。""" + +from __future__ import annotations + +import os +from typing import Callable +from urllib.parse import parse_qsl, urlencode, urlsplit + +from flask import Flask, Response, jsonify, redirect, request, session +from jinja2 import ChoiceLoader, FileSystemLoader + +EMBED_TABS: tuple[str, ...] = ( + "key_monitor", + "trade", + "strategy", + "strategy_records", + "records", + "stats", +) + +PATH_TO_EMBED_TAB: dict[str, str] = { + "/": "trade", + "/trade": "trade", + "/key_monitor": "key_monitor", + "/strategy": "strategy", + "/strategy/trend": "strategy", + "/strategy/roll": "strategy", + "/strategy/records": "strategy_records", + "/records": "records", + "/stats": "stats", +} + +ORDER_RULE_TIPS_BY_EXCHANGE: dict[str, str] = { + "gate": "order_monitor_rule_tips_gate.html", + "gate_bot": "order_monitor_rule_tips_gate.html", + "binance": "order_monitor_rule_tips_binance.html", + "okx": "order_monitor_rule_tips_okx.html", +} + + +def order_rule_tips_template(exchange_key: str) -> str: + ex = (exchange_key or "").strip().lower() + return ORDER_RULE_TIPS_BY_EXCHANGE.get(ex, "order_monitor_rule_tips_gate.html") + + +def include_transfer_block(exchange_key: str) -> bool: + return (exchange_key or "").strip().lower() in ("gate", "gate_bot") + + +def path_to_embed_tab(path: str) -> str | None: + p = (path or "/").strip() + if not p.startswith("/"): + p = "/" + p + base = urlsplit(p).path.rstrip("/") or "/" + return PATH_TO_EMBED_TAB.get(base) + + +def rewrite_embed_dest(path: str, hub_theme: str | None = None) -> str: + """embed=1 打开时:/trade → /embed?tab=trade&embed=1""" + split = urlsplit(path or "/") + tab = path_to_embed_tab(split.path) + q = dict(parse_qsl(split.query, keep_blank_values=True)) + if tab: + q["tab"] = tab + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + return f"/embed?{urlencode(q)}" + q["embed"] = "1" + ht = (hub_theme or q.get("hub_theme") or "").strip().lower() + if ht in ("light", "dark"): + q["hub_theme"] = ht + dest = split.path or "/" + if split.query: + dest += "?" + split.query + if "embed=1" not in dest: + sep = "&" if "?" in dest else "?" + dest += f"{sep}embed=1" + if ht in ("light", "dark") and "hub_theme=" not in dest: + sep = "&" if "?" in dest else "?" + dest += f"{sep}hub_theme={ht}" + return dest + + +def attach_embed_templates(app: Flask, repo_root: str) -> None: + embed_dir = os.path.join(repo_root, "embed_templates") + if not os.path.isdir(embed_dir): + return + existing = app.jinja_loader + loaders = [FileSystemLoader(embed_dir)] + if existing is not None: + if isinstance(existing, ChoiceLoader): + loaders = list(existing.loaders) + loaders + else: + loaders.insert(0, existing) + app.jinja_loader = ChoiceLoader(loaders) + + +def register_embed_routes( + app: Flask, + login_required: Callable, + render_main_page_fn: Callable, +) -> None: + app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn + + @login_required + @app.route("/embed") + def embed_shell_page(): + tab = (request.args.get("tab") or "trade").strip() + if tab not in EMBED_TABS: + tab = "trade" + session["hub_embed_shell"] = True + return render_main_page_fn(tab, embed_mode="shell") + + @login_required + @app.route("/api/embed/page/") + def api_embed_page(tab: str): + tab = (tab or "").strip() + if tab not in EMBED_TABS: + return jsonify({"ok": False, "msg": "unknown tab"}), 404 + html = render_main_page_fn(tab, embed_mode="fragment") + if isinstance(html, Response): + html = html.get_data(as_text=True) + return jsonify({"ok": True, "page": tab, "html": html}) + + +def embed_context_extras(exchange_key: str) -> dict: + return { + "order_rule_tips_tpl": order_rule_tips_template(exchange_key), + "include_transfer_block": include_transfer_block(exchange_key), + } diff --git a/scripts/build_embed_fragment.py b/scripts/build_embed_fragment.py new file mode 100644 index 0000000..8baec0e --- /dev/null +++ b/scripts/build_embed_fragment.py @@ -0,0 +1,33 @@ +"""Build embed_page_fragment.html from gate index.html.""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +src_lines = (ROOT / "crypto_monitor_gate" / "templates" / "index.html").read_text( + encoding="utf-8" +).splitlines() + +# 1-based line numbers from index.html +macro_body = src_lines[243:262] # {% macro %} … {% endmacro %} +grid_inner = src_lines[328:736] # inside .grid (exclude outer wrapper) +stats_block = src_lines[738:772] + +out_lines = [ + "{# Hub iframe tab fragment — shared via embed_templates #}", + *macro_body, + '
', + *grid_inner, + "
", + *stats_block, +] + +out_dir = ROOT / "embed_templates" +out_dir.mkdir(exist_ok=True) +text = "\n".join(out_lines) + "\n" +text = text.replace( + "{% include 'order_monitor_rule_tips_gate.html' %}", + "{% include order_rule_tips_tpl %}", +) +(out_dir / "embed_page_fragment.html").write_text(text, encoding="utf-8") +print("wrote", out_dir / "embed_page_fragment.html", "lines", len(out_lines)) diff --git a/scripts/extract_instance_page_assets.py b/scripts/extract_instance_page_assets.py new file mode 100644 index 0000000..7902db6 --- /dev/null +++ b/scripts/extract_instance_page_assets.py @@ -0,0 +1,49 @@ +"""One-off: extract instance_page.css / instance_page_boot.js from gate index.html.""" +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +src = ROOT / "crypto_monitor_gate" / "templates" / "index.html" +text = src.read_text(encoding="utf-8") + +m = re.search(r"", text, re.S) +if m: + (ROOT / "static" / "instance_page.css").write_text(m.group(1).strip() + "\n", encoding="utf-8") + +marker = '' +if marker in text: + part = text.split(marker, 1)[1] + m2 = re.search(r"\s*", part, re.S) + if m2: + boot = m2.group(1).strip() + boot = boot.replace( + "setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});", + "setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));", + ) + boot = boot.replace( + "setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});", + "setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));", + ) + (ROOT / "static" / "instance_page_boot.js").write_text(boot + "\n", encoding="utf-8") + + part2 = text.split(marker, 1)[1] + m3 = re.search(r"\s*", part2, re.S) + if m3: + boot_tpl = m3.group(1).strip() + boot_tpl = boot_tpl.replace( + "setInterval(refreshAccountSnapshot, {{ balance_refresh_seconds * 1000 }});", + "setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));", + ) + boot_tpl = boot_tpl.replace( + "setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});", + "setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));", + ) + embed_dir = ROOT / "embed_templates" + embed_dir.mkdir(exist_ok=True) + (embed_dir / "embed_boot_scripts.html").write_text( + "\n", encoding="utf-8" + ) + +print("done") diff --git a/static/instance_embed.js b/static/instance_embed.js new file mode 100644 index 0000000..d890cd2 --- /dev/null +++ b/static/instance_embed.js @@ -0,0 +1,231 @@ +/** + * 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/。 + */ +(function (global) { + const TAB_PATH = { + key_monitor: "/key_monitor", + trade: "/trade", + strategy: "/strategy", + strategy_records: "/strategy/records", + records: "/records", + stats: "/stats", + }; + + let navToken = 0; + let loadingTab = false; + + function isEmbedShell() { + return document.body && document.body.getAttribute("data-embed-shell") === "1"; + } + + function getTab() { + try { + const t = new URLSearchParams(location.search).get("tab"); + if (t) return t; + } catch (_) {} + return document.body.getAttribute("data-page") || "trade"; + } + + function listWindowQueryString() { + if (typeof global.listWindowQueryString === "function") { + return global.listWindowQueryString(); + } + return ""; + } + + function notifyParentNavStart() { + try { + window.parent.postMessage({ type: "instance-frame-navigating" }, "*"); + } catch (_) {} + } + + function notifyParentReady() { + try { + window.parent.postMessage({ type: "instance-frame-ready" }, "*"); + } catch (_) {} + } + + function setNavActive(tab) { + document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => { + a.classList.toggle("active", a.getAttribute("data-embed-tab") === tab); + }); + } + + function syncUrl(tab, replace) { + const q = new URLSearchParams(location.search); + q.set("tab", tab); + q.set("embed", "1"); + const qs = q.toString(); + const url = "/embed?" + qs; + if (replace) history.replaceState({ embedTab: tab }, "", url); + else history.pushState({ embedTab: tab }, "", url); + } + + function runPageInit(tab) { + document.body.setAttribute("data-page", tab); + if (typeof global.attachListWindowToExports === "function") { + global.attachListWindowToExports(); + } + if (tab === "trade") { + if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults(); + } + if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") { + global.KeyMonitorForm.init(); + } + if (tab === "records") { + if (typeof global.loadJournals === "function") global.loadJournals(); + if (typeof global.loadReviews === "function") global.loadReviews(); + if (typeof global.toggleReviewMode === "function") global.toggleReviewMode(); + } + if (typeof global.refreshPriceSnapshotConditional === "function") { + global.refreshPriceSnapshotConditional(); + } + } + + function injectFragment(html) { + const root = document.getElementById("embed-page-root"); + if (!root) return; + root.innerHTML = html; + root.querySelectorAll("script").forEach((old) => { + const s = document.createElement("script"); + if (old.src) s.src = old.src; + else s.textContent = old.textContent; + old.replaceWith(s); + }); + } + + async function loadTab(tab, opts) { + const options = opts || {}; + if (!tab || loadingTab) return; + const token = ++navToken; + loadingTab = true; + notifyParentNavStart(); + try { + const qs = listWindowQueryString(); + const url = "/api/embed/page/" + encodeURIComponent(tab) + (qs ? "?" + qs : ""); + const r = await fetch(url, { credentials: "same-origin" }); + if (token !== navToken) return; + const j = await r.json(); + if (!j.ok || !j.html) throw new Error(j.msg || "加载失败"); + injectFragment(j.html); + setNavActive(tab); + if (!options.skipUrl) syncUrl(tab, !!options.replace); + runPageInit(tab); + } catch (e) { + if (token === navToken) { + const flash = document.getElementById("embed-flash"); + if (flash) { + flash.style.display = ""; + flash.textContent = String(e && e.message ? e.message : e); + } + } + } finally { + if (token === navToken) { + loadingTab = false; + notifyParentReady(); + } + } + } + + function reloadCurrentTab() { + return loadTab(getTab(), { replace: true, skipUrl: true }); + } + + function patchApplyListWindow() { + if (typeof global.applyListWindow !== "function") return; + global.applyListWindow = function embedApplyListWindow() { + void loadTab(getTab(), { replace: true }); + }; + } + + function patchHardNavigations() { + const resubmitPaths = + /^\/(del_|delete_|add_|stop_|strategy\/|trend_|roll_|cancel_|place_)/; + + document.addEventListener( + "click", + (ev) => { + if (!isEmbedShell()) return; + const a = ev.target.closest("a[href]"); + if (!a || ev.defaultPrevented) return; + if (a.closest(".embed-top-nav")) return; + if (a.hasAttribute("download") || a.target === "_blank") return; + const raw = a.getAttribute("href"); + if (!raw || raw.startsWith("#") || raw.startsWith("javascript:")) return; + let url; + try { + url = new URL(raw, location.href); + } catch (_) { + return; + } + if (url.origin !== location.origin) return; + if (url.pathname.startsWith("/export/") || url.pathname.startsWith("/order_focus") || url.pathname.startsWith("/key_focus")) { + return; + } + if (!resubmitPaths.test(url.pathname)) return; + ev.preventDefault(); + fetch(url.pathname + url.search, { credentials: "same-origin", redirect: "manual" }) + .then(() => reloadCurrentTab()) + .catch(() => reloadCurrentTab()); + }, + false + ); + + document.addEventListener( + "submit", + (ev) => { + if (!isEmbedShell()) return; + const form = ev.target; + if (!(form instanceof HTMLFormElement)) return; + if (form.method && form.method.toUpperCase() === "GET") return; + ev.preventDefault(); + const fd = new FormData(form); + fetch(form.action, { + method: form.method || "POST", + body: fd, + credentials: "same-origin", + redirect: "manual", + }) + .then(() => reloadCurrentTab()) + .catch(() => reloadCurrentTab()); + }, + true + ); + } + + function bindNav() { + document.querySelectorAll(".embed-top-nav [data-embed-tab]").forEach((a) => { + a.addEventListener("click", (ev) => { + ev.preventDefault(); + const tab = a.getAttribute("data-embed-tab"); + if (!tab || tab === getTab()) return; + void loadTab(tab); + }); + }); + window.addEventListener("popstate", () => { + const tab = getTab(); + void loadTab(tab, { replace: true, skipUrl: true }); + }); + } + + function boot() { + if (!isEmbedShell()) return; + patchApplyListWindow(); + patchHardNavigations(); + bindNav(); + runPageInit(getTab()); + notifyParentReady(); + } + + global.InstanceEmbed = { + loadTab, + reloadCurrentTab, + getTab, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/static/instance_page.css b/static/instance_page.css new file mode 100644 index 0000000..0a1bdfc --- /dev/null +++ b/static/instance_page.css @@ -0,0 +1,220 @@ +*{margin:0;padding:0;box-sizing:border-box} + body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;background:#0b0d14;color:#eaeaea;padding:14px 20px} + .container{width:100%;max-width:min(1440px,94vw);margin:0 auto;padding:0 clamp(8px,1.5vw,20px)} + .header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} + .header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25} + .exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em} + .header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center} + .top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px} + .top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none} + .top-nav a.active{background:#2a3f6c;color:#dbe4ff} + .stat-box{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:12px;margin-bottom:16px;align-items:stretch} + .stat-item{min-width:0;min-height:76px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;background:#151a2a;padding:12px 10px;border-radius:10px;text-align:center;border:1px solid #2a3152} + .stat-item .label{font-size:.8rem;color:#aaa;line-height:1.25;max-width:100%} + .stat-item .value{font-size:1.25rem;font-weight:600;color:#fff;line-height:1.3;min-height:1.35em;display:flex;align-items:center;justify-content:center} + .grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px} + .card{background:#121726;border-radius:10px;padding:12px;border:1px solid #2a3150} + .full{grid-column:1/-1} + .card h2{font-size:1rem;margin-bottom:10px;color:#d4d9ff} + .form-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center} + .form-row > input:not([type=checkbox]):not([type=radio]),.form-row > select{flex:0 1 auto;width:10rem;max-width:200px;min-width:7rem} + #add-order-form #sltp-mode{min-width:12.5rem;max-width:16rem;width:auto} + .form-row > button,.form-row > label{flex:0 0 auto} + .form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px} + /* 复盘表单:长下拉文案需可收缩,否则会撑破四列网格 */ + .journal-card .form-grid{grid-template-columns:repeat(4,minmax(0,1fr))} + .journal-card .form-grid > input, + .journal-card .form-grid > select{ + min-width:0; + width:100%; + max-width:100%; + } + .journal-card .form-grid select[name="entry_reason"]{ + grid-column:1/-1; + font-size:.8rem; + line-height:1.35; + } + .journal-card .form-grid input[name="entry_reason_custom"]{ + grid-column:1/-1; + font-size:.8rem; + } + input,select,button,textarea{padding:8px 10px;border-radius:8px;border:1px solid #2e2e45;background:#1a1a29;color:#fff;font-size:.88rem;outline:none} + button{background:linear-gradient(90deg,#4285f4,#7b42ff);border:none;cursor:pointer} + .list{display:flex;flex-direction:column;gap:8px;margin-top:8px;max-height:240px;overflow:auto} + .list-item{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px;background:#1a2034;border:1px solid #2a3150;border-radius:8px} + .btn-del{padding:5px 9px;background:#2f2134;color:#ff7b7b;border-radius:8px;text-decoration:none;font-size:.8rem} + .rule-tip{font-size:.8rem;color:#95a2c2;margin-bottom:8px} + table{width:100%;border-collapse:collapse} + th,td{padding:8px;text-align:left;border-bottom:1px solid #25253b;font-size:.85rem} + th{color:#a9a9ff} + .badge{padding:2px 6px;border-radius:6px;font-size:.72rem} + .profit{background:#1e332f;color:#4cd97f} + .loss{background:#331e24;color:#ff6666} + .miss{background:#29241e;color:#eac147} + .direction{background:#1e2533;color:#4cc2ff} + .direction-long{background:#1e332f;color:#4cd97f} + .direction-short{background:#331e24;color:#ff6666} + .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} + .ai-result-md ul,.ai-result-md ol,.detail-modal .panel-body.md-review ul,.detail-modal .panel-body.md-review ol{margin:6px 0 8px 1.25em;padding:0} + .ai-result-md li,.detail-modal .panel-body.md-review li{margin:5px 0;line-height:1.5} + .ai-result-md strong,.detail-modal .panel-body.md-review strong{color:#f0f3ff;font-weight:600} + .ai-result-md h2,.detail-modal .panel-body.md-review h2{font-size:1.02rem;color:#b8c8ff;margin:14px 0 8px;padding-bottom:4px;border-bottom:1px solid #2e2e45} + .ai-result-md h3,.detail-modal .panel-body.md-review h3{font-size:.92rem;color:#c9d4ff;margin:10px 0 6px} + .ai-result-md code,.detail-modal .panel-body.md-review code{background:#252538;padding:1px 4px;border-radius:4px;font-size:.82em} + .ai-result-md .md-raw-block-title,.detail-modal .panel-body.md-review .md-raw-block-title{margin-top:14px;padding-top:10px;border-top:1px dashed #3a3a55;color:#a8b0d8;font-weight:600} + .price-up{color:#4cd97f} + .price-down{color:#ff6666} + .price-flat{color:#cfd3ef} + .panel-list{display:grid;grid-template-columns:1fr 1fr;gap:12px} + .panel-item{background:#141423;border:1px solid #24243b;border-radius:10px;padding:10px;max-height:260px;overflow:auto} + .entry{border-bottom:1px solid #2b2b43;padding:8px 0} + .entry:last-child{border-bottom:none} + .table-del{padding:4px 8px;background:#2f2134;color:#ff7b7b;border:none;border-radius:6px;cursor:pointer;font-size:.78rem} + .mood-grid{display:flex;gap:10px;flex-wrap:wrap;font-size:.82rem;color:#d7d7ea} + .mood-grid label{display:flex;align-items:center;gap:3px} + .screenshot{width:100px;border-radius:6px;cursor:pointer;margin-top:6px} + .modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1210} + .modal img{max-width:90%;max-height:90%;border-radius:8px} + .detail-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.78);justify-content:center;align-items:center;z-index:1200;padding:20px} + .detail-modal .panel{width:min(92vw,980px);max-height:88vh;overflow:auto;background:#121726;border:1px solid #2a3150;border-radius:10px;padding:14px} + .detail-modal .panel-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px} + .detail-modal .panel-title{font-size:1rem;color:#dbe4ff} + .detail-modal .panel-close{padding:6px 10px;background:#2f2134;color:#ffb2b2;border:none;border-radius:8px;cursor:pointer} + .detail-modal .panel-body{white-space:pre-wrap;line-height:1.5;font-size:.86rem;color:#e5e9ff} + .detail-modal .panel-image{margin-top:10px;max-width:min(100%,680px);border-radius:8px;cursor:pointer;border:1px solid #2a3150} + .detail-modal .panel-actions{display:flex;gap:8px;align-items:center;flex-shrink:0} + .detail-modal .panel-fs{padding:6px 10px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem} + .detail-modal.fullscreen{padding:10px} + .detail-modal.fullscreen .panel{width:100%;height:100%;max-width:none;max-height:none;display:flex;flex-direction:column;overflow:hidden} + .detail-modal.fullscreen .panel-body{flex:1;overflow:auto;min-height:0;font-size:.9rem} + .ai-result-wrap{margin-top:8px} + .ai-result-toolbar{display:flex;gap:8px;margin-top:6px} + .ai-result-toolbar .btn-fs{padding:4px 10px;font-size:.78rem;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:6px;cursor:pointer} + .table-wrap{overflow-x:auto} + .dual-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:stretch} + .dual-panel-grid .card{height:100%;display:flex;flex-direction:column} + .panel-scroll{flex:1;min-height:280px;max-height:420px;overflow:auto} + .records-card{grid-column:1/-1} + .review-card{grid-column:1/-1} + .review-card-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap} + .review-card-head h2{margin:0} + .review-card-fs-btn{padding:6px 12px;background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;cursor:pointer;font-size:.82rem;white-space:nowrap} + .review-card-fs-btn:hover{filter:brightness(1.08)} + body.review-card-fullscreen-open{overflow:hidden} + .review-card.is-fullscreen{ + position:fixed;inset:12px;z-index:1100;margin:0; + width:auto !important;max-width:none;height:auto; + overflow:auto;display:flex;flex-direction:column; + box-shadow:0 12px 48px rgba(0,0,0,.55); + } + .review-card.is-fullscreen .panel-list{flex:1;min-height:320px} + .review-card.is-fullscreen .panel-item{max-height:none;height:auto;min-height:280px} + .review-card.is-fullscreen .ai-result{max-height:min(36vh, 320px)} + @media (max-width: 1200px){ + .stat-box{grid-template-columns:repeat(auto-fill,minmax(140px,1fr))} + } + @media (min-width: 1440px){ + .panel-scroll,.pos-list{max-height:420px} + .records-card .table-wrap{max-height:620px;overflow:auto} + } + @media (min-width: 2200px){ + .container{max-width:min(1720px,90vw)} + } + @media (min-width: 2560px){ + .container{max-width:min(1860px,88vw)} + .dual-panel-grid{gap:18px} + } + @media (min-width: 3000px){ + .container{max-width:min(1980px,86vw)} + .pos-grid{grid-template-columns:repeat(4,minmax(0,1fr))} + } + @media (max-width: 1100px){ + .grid{grid-template-columns:1fr} + .dual-panel-grid{grid-template-columns:1fr} + .records-card,.review-card{grid-column:auto} + .panel-list{grid-template-columns:1fr} + } + @media (max-width: 960px){ + body{padding:10px} + .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))} + .stat-box{grid-template-columns:repeat(2,minmax(0,1fr))} + } + .stats-detail{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px;margin-top:10px} + .stats-detail .stat-item{min-width:0;min-height:0;display:block;text-align:left;padding:10px 12px;align-items:stretch;gap:4px} + .stats-detail .stat-item .value{min-height:0;display:block;font-size:1.05rem} + .stats-detail .stat-item .label{font-size:.75rem} + .stats-detail .stat-item .value{font-size:1.05rem;word-break:break-all} + .export-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;font-size:.85rem} + .export-bar a{color:#8fc8ff;text-decoration:none;padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a} + .export-bar a:hover{background:#1f2740} + .list-window-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:12px;padding:10px 12px;background:#151a2a;border:1px solid #304164;border-radius:10px;font-size:.82rem} + .list-window-bar label{color:#9aa;display:flex;align-items:center;gap:6px} + .stats-segment-block{margin-top:20px;padding-top:14px;border-top:1px solid #3a4468} + .stats-segment-block h2{font-size:1.05rem;color:#dbe4ff;margin-bottom:8px} + .key-history{margin-top:12px;padding-top:10px;border-top:1px solid #2a3150} + .key-history h3{font-size:.88rem;color:#b8c4ff;margin-bottom:6px} + .key-history .sub{font-size:.72rem;color:#8892b0;margin-bottom:6px} + .key-history .list{max-height:200px} + .pos-section{margin-top:12px} + .pos-section-title{font-size:.82rem;color:#8892b0;margin-bottom:8px;font-weight:500} + .pos-list{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto} + .dual-panel-grid .pos-list-live{max-height:none;overflow:visible;flex:1 1 auto} + .dual-panel-grid .panel-scroll.pos-list-live{max-height:none;overflow:visible} + .pos-card{background:#141923;border:1px solid #2a3348;border-radius:10px;padding:12px 14px} + .pos-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:10px} + .pos-meta{font-size:.74rem;color:#8b95a8;line-height:1.45;margin-bottom:12px;display:flex;flex-wrap:wrap;align-items:center;gap:4px 0} + .pos-meta-item{display:inline-flex;align-items:center} + .pos-meta-item:not(:last-child)::after{content:'|';margin:0 8px;color:#3d4659} + .pos-meta-on{color:#6eb5ff} + .pos-meta-off{color:#7d8799} + .pos-breakeven-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:6px;font-size:.72rem;font-weight:600;background:#1a3d2e;color:#4cd97f} + .pos-card-symbol{display:flex;align-items:center;gap:8px;flex-wrap:wrap;min-width:0} + .pos-card-symbol strong{font-size:.95rem;color:#fff;font-weight:600} + .pos-side-badge{padding:3px 8px;border-radius:6px;font-size:.72rem;font-weight:500;line-height:1.2} + .pos-side-long{background:#253a6e;color:#6eb5ff} + .pos-side-short{background:#4a2230;color:#ff8a8a} + .pos-head-actions{display:flex;align-items:center;gap:6px;flex-shrink:0} + .pos-entrust-btn{padding:6px 12px;background:#2a4a7a;color:#8fc8ff;border:none;border-radius:8px;font-size:.82rem;font-weight:500;cursor:pointer;white-space:nowrap} + .pos-entrust-btn:hover{background:#355d96} + .pos-close-btn{padding:6px 14px;background:#c45454;color:#fff;border-radius:8px;text-decoration:none;font-size:.82rem;font-weight:500;flex-shrink:0;white-space:nowrap;border:none;cursor:pointer;display:inline-block} + .pos-close-btn:hover{background:#d66565;color:#fff} + .pos-ex-orders{margin-top:10px;padding-top:10px;border-top:1px dashed #2a3348} + .pos-ex-orders-title{font-size:.74rem;color:#7d8799;margin-bottom:6px} + .pos-ex-order-row{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:.78rem;color:#c5cce0;margin-top:5px} + .pos-ex-order-main{flex:1;min-width:0;line-height:1.35} + .pos-ex-cancel-btn{padding:3px 10px;background:#3a3048;color:#d4b8ff;border:none;border-radius:6px;font-size:.74rem;cursor:pointer;flex-shrink:0} + .pos-ex-cancel-btn:disabled{opacity:.4;cursor:not-allowed} + .tpsl-modal-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9000;align-items:center;justify-content:center;padding:16px} + .tpsl-modal-backdrop.open{display:flex} + .tpsl-modal{background:#1a2030;border:1px solid #3a4a66;border-radius:12px;padding:16px 18px;width:min(440px,100%);max-height:90vh;overflow:auto} + .tpsl-modal h3{margin:0 0 12px;font-size:1rem;color:#fff} + .tpsl-modal .form-row{margin-bottom:10px} + .tpsl-modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:14px} + .tpsl-modal-actions button{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:.85rem} + .tpsl-modal-submit{background:#2d6a4f;color:#fff} + .tpsl-modal-cancel{background:#3a3f52;color:#ddd} + .pos-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px 14px;margin-bottom:12px} + .pos-cell{display:flex;flex-direction:column;gap:4px;min-width:0} + .pos-label{font-size:.72rem;color:#7d8799} + .pos-value{font-size:.88rem;color:#e8ecf4;font-weight:500;line-height:1.25} + .pos-val-dash{opacity:.75;color:#8b95a8} + .pos-value.price-up{color:#4cd97f} + .pos-value.price-down{color:#ff6666} + .pos-value.price-flat{color:#e8ecf4} + .pos-footer{display:flex;flex-wrap:wrap;gap:14px 18px;font-size:.75rem;color:#6d7689} + .pos-empty{padding:18px;text-align:center;color:#8892b0;font-size:.85rem;background:#141923;border:1px dashed #2a3348;border-radius:10px} + @media (max-width:520px){.pos-grid{grid-template-columns:repeat(2,1fr)}} + .stats-card{grid-column:1/-1;margin-top:14px} + .stats-card .stats-toggle{background:#1f3a5a;color:#8fc8ff;border:none;border-radius:8px;padding:6px 10px;cursor:pointer} + .stats-card.collapsed .stats-content{display:none} + .stats-period-block{margin-bottom:18px;padding-bottom:14px;border-bottom:1px solid #2a3150} + .stats-period-block:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0} + .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} diff --git a/static/instance_theme.js b/static/instance_theme.js index 05e30d5..01188a5 100644 --- a/static/instance_theme.js +++ b/static/instance_theme.js @@ -406,6 +406,7 @@ /** 中控 iframe:fetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白。 */ function initHubEmbedInFrameNav() { if (!isHubLinked()) return; + if (document.body && document.body.getAttribute("data-embed-shell") === "1") return; let navToken = 0; diff --git a/tests/test_instance_embed_lib.py b/tests/test_instance_embed_lib.py new file mode 100644 index 0000000..72700cc --- /dev/null +++ b/tests/test_instance_embed_lib.py @@ -0,0 +1,26 @@ +from instance_embed_lib import ( + EMBED_TABS, + path_to_embed_tab, + rewrite_embed_dest, +) + + +def test_path_to_embed_tab(): + assert path_to_embed_tab("/trade") == "trade" + assert path_to_embed_tab("/key_monitor") == "key_monitor" + assert path_to_embed_tab("/strategy/records") == "strategy_records" + assert path_to_embed_tab("/unknown") is None + + +def test_rewrite_embed_dest(): + url = rewrite_embed_dest("/trade", hub_theme="dark") + assert url.startswith("/embed?") + assert "tab=trade" in url + assert "embed=1" in url + assert "hub_theme=dark" in url + + +def test_embed_tabs_cover_main_nav(): + assert "trade" in EMBED_TABS + assert "key_monitor" in EMBED_TABS + assert "records" in EMBED_TABS