Add hub iframe embed shell with tab fragment API.

Replace full-page soft nav with a persistent shell and /api/embed/page loads so tab switches in the hub iframe avoid document.write flicker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 01:13:34 +08:00
parent 157d9ada21
commit 4ad335ca84
15 changed files with 2711 additions and 31 deletions
+12 -3
View File
@@ -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}")
+20 -4
View File
@@ -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}")
+16 -4
View File
@@ -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}")
+16 -4
View File
@@ -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}")
File diff suppressed because it is too large Load Diff
+464
View File
@@ -0,0 +1,464 @@
{# Hub iframe tab fragment — shared via embed_templates #}
{% macro period_stats(title, s) %}
<div class="stats-period-block">
<h3>{{ title }}</h3>
<div class="sub">{{ s.range_label }}</div>
<div class="stats-detail">
<div class="stat-item"><div class="label">开单次数</div><div class="value">{{ s.opens_count }}</div></div>
<div class="stat-item"><div class="label">平仓笔数</div><div class="value">{{ s.closed_count }}</div></div>
<div class="stat-item"><div class="label">胜率</div><div class="value">{% if s.win_rate_pct is not none %}{{ s.win_rate_pct }}%{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">净盈亏(U)</div><div class="value">{{ funds_fmt(s.net_pnl_u) }}</div></div>
<div class="stat-item"><div class="label">亏损额合计(U)</div><div class="value">{{ funds_fmt(s.loss_sum_u) }}</div></div>
<div class="stat-item"><div class="label">单笔最大亏损(U)</div><div class="value">{% if s.max_single_loss is not none %}{{ funds_fmt(s.max_single_loss) }}{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">单笔最大盈利(U)</div><div class="value">{% if s.max_single_profit is not none %}{{ funds_fmt(s.max_single_profit) }}{% else %}-{% endif %}</div></div>
<div class="stat-item"><div class="label">最大回撤(U)</div><div class="value">{{ funds_fmt(s.max_drawdown_u) }}</div></div>
<div class="stat-item"><div class="label">当前连续亏损笔数</div><div class="value">{{ s.consecutive_losses }}</div></div>
<div class="stat-item"><div class="label">最长连续亏损(交易日)</div><div class="value">{{ s.max_loss_streak_days }} 天</div></div>
<div class="stat-item"><div class="label">期内最大亏损日</div><div class="value">{% if s.worst_day %}{{ s.worst_day }}{{ funds_fmt(s.worst_day_pnl) }}U{% else %}-{% endif %}</div></div>
</div>
</div>
{% endmacro %}
<div class="grid">
{% if page == 'key_monitor' %}
{% include 'key_monitor_panel.html' %}
{% elif page == 'trade' %}
<div class="dual-panel-grid" style="grid-column:1/-1">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:8px">
<h2 style="margin-bottom:0">实盘下单监控</h2>
{% if focus_order_id %}
<a href="/order_focus?order_id={{ focus_order_id }}" class="btn-del" style="text-decoration:none;background:#1f3a5a;color:#8fc8ff">放大查看K线(100根)</a>
{% else %}
<span class="btn-del" style="background:#2f2f44;color:#9aa;cursor:not-allowed">暂无持仓可放大</span>
{% endif %}
</div>
{% include order_rule_tips_tpl %}
<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>
</select>
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
<option value="pct">止盈止损:百分比模式</option>
</select>
<select name="trade_style" required>
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
{% if position_sizing_mode != 'full_margin' %}
<input id="order-leverage" name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)">
{% endif %}
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="breakeven_enabled" value="1" checked> 启用移动保本(关闭则仅保留初始止损与交易所挂单)
</label>
<span id="order-time-close-wrap" class="order-time-close-wrap" style="display:inline-flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<label style="display:inline-flex;align-items:center;gap:4px;margin:0;cursor:pointer">
<input type="checkbox" name="time_close_enabled" value="1" id="order-time-close-cb"> 时间平仓
</label>
<select name="time_close_hours" id="order-time-close-hours" title="持仓满该时长后自动平仓">
<option value="1">1h</option>
<option value="2">2h</option>
<option value="4" selected>4h</option>
</select>
</span>
<label style="display:flex;align-items:center;gap:4px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="order_chart" value="true"> 开仓后生成多周期K线图(各周期100根,含开平仓标记)
</label>
<span style="display:flex;align-items:center;padding:0 10px;font-size:.8rem;color:#8fc8ff">成交价自动取交易所实时+成交回报</span>
<input id="order-sl" name="sl" step="any" placeholder="止损价格" required>
<input id="order-fixed-rr" name="fixed_rr" type="number" min="0.01" step="0.01" placeholder="盈亏比(默认1.5)" value="1.5" title="止盈距离=止损距离×盈亏比">
<span id="order-tp-preview" style="display:none;font-size:.8rem;color:#8fc8ff;align-self:center">预估止盈:—</span>
<input id="order-tp" name="tgt" step="any" placeholder="止盈价格" style="display:none">
<input id="order-sl-pct" name="sl_pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="order-tp-pct" name="tp_pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
<span id="order-rr-preview" class="order-rr-preview" style="display:none;font-size:.82rem;color:#8fc8ff;align-self:center">预估盈亏比:—</span>
<button type="submit">{{ open_position_button_label }}</button>
</form>
</div>
<div class="card">
<h2 style="margin-bottom:8px">实时持仓</h2>
<div class="panel-scroll pos-list pos-list-live">
{% for o in order %}
<div class="pos-card" id="order-row-{{ o.id }}"
data-monitor-id="{{ o.id }}"
data-symbol="{{ o.symbol }}"
data-direction="{{ o.direction }}"
data-plan-sl="{% if o.stop_loss %}{{ price_fmt(o.symbol, o.stop_loss) }}{% endif %}"
data-plan-tp="{% if o.take_profit %}{{ price_fmt(o.symbol, o.take_profit) }}{% endif %}"
data-entry="{% if o.trigger_price %}{{ price_fmt(o.symbol, o.trigger_price) }}{% endif %}">
<div class="pos-card-head">
<div class="pos-card-symbol">
<strong>{{ o.exchange_symbol or o.symbol }}</strong>
{% if o.time_close_enabled %}
<span class="pos-symbol-time-close pos-meta-on pos-time-close-meta" id="order-time-close-wrap-{{ o.id }}"
data-close-at-ms="{{ o.time_close_at_ms or '' }}">
<span class="pos-time-close-label">时间平仓 {{ o.time_close_hours or '' }}h</span>
· <span class="pos-time-close-cd" id="order-time-close-cd-{{ o.id }}">--:--:--</span>
</span>
{% endif %}
<span class="pos-side-badge {{ 'pos-side-long' if o.direction == 'long' else 'pos-side-short' }}">{{ '做多' if o.direction == 'long' else '做空' }}</span>
</div>
<div class="pos-head-actions">
<button type="button" class="pos-entrust-btn" onclick="openTpslEntrustModal({{ o.id }})">委托</button>
<a href="/del_order/{{ o.id }}" class="pos-close-btn" onclick="return confirm('删除会触发手动平仓,继续?')">平仓</a>
</div>
</div>
<div class="pos-meta">
<span class="pos-meta-item">来源: {{ o.monitor_type|default('下单监控', true) }}{% if o.key_signal_type %} · {{ o.key_signal_type }}{% endif %}</span>
<span class="pos-meta-item">风格: {{ o.trade_style or 'trend' }}</span>
<span class="pos-meta-item">风险: {% 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 %}</span>
<span class="pos-meta-item {% if o.breakeven_enabled %}pos-meta-on{% else %}pos-meta-off{% endif %}">
{% if o.breakeven_enabled %}移动保本:开 {{ o.breakeven_rr_trigger or '-' }}R→{{ price_fmt(o.symbol, o.breakeven_price) }}{% else %}移动保本:关{% endif %}
</span>
<span class="pos-meta-item" id="order-be-wrap-{{ o.id }}" style="display:none"><span class="pos-breakeven-badge">已保本</span></span>
</div>
<div class="pos-grid">
<div class="pos-cell">
<span class="pos-label">成交价</span>
<span class="pos-value">{{ price_fmt(o.symbol, o.trigger_price) }}</span>
</div>
<div class="pos-cell">
<span class="pos-label">止损</span>
<span class="pos-value" id="order-plan-sl-{{ o.id }}">{{ price_fmt(o.symbol, o.stop_loss) if o.stop_loss else '—' }}</span>
</div>
<div class="pos-cell">
<span class="pos-label">止盈</span>
<span class="pos-value" id="order-plan-tp-{{ o.id }}">{{ price_fmt(o.symbol, o.take_profit) if o.take_profit else '—' }}</span>
</div>
<div class="pos-cell">
<span class="pos-label">盈亏比</span>
<span class="pos-value" id="order-rr-{{ o.id }}">{% if o.rr_ratio is not none %}{{ '%g'|format(o.rr_ratio) }}:1{% else %}-:1{% endif %}</span>
</div>
<div class="pos-cell">
<span class="pos-label">标记价</span>
<span class="pos-value" id="order-price-{{ o.id }}">-</span>
</div>
<div class="pos-cell">
<span class="pos-label">浮盈亏</span>
<span class="pos-value" id="order-pnl-{{ o.id }}">-</span>
</div>
</div>
<div class="pos-footer">
<span>保证金: <span id="order-ex-margin-{{ o.id }}">-</span></span>
<span>计划基数: {{ funds_fmt(o.margin_capital) if o.margin_capital is not none else '-' }}U</span>
<span>杠杆: {{ o.leverage or '-' }}x</span>
<span>仓位占比: {{ o.position_ratio if o.position_ratio is not none else '-' }}%</span>
<span>开仓时间: {{ (o.opened_at or '-')[:16] }}</span>
<span>持仓时长: <span class="order-hold-duration" id="order-hold-duration-{{ o.id }}" data-order-opened-ms="{{ o.opened_at_ms or '' }}"></span></span>
</div>
<div class="pos-ex-orders">
<div class="pos-ex-orders-title">交易所止盈止损</div>
<div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-sl-text-{{ o.id }}">止损:加载中…</span>
<button type="button" class="pos-ex-cancel-btn" id="ex-sl-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'sl')">撤单</button>
</div>
<div class="pos-ex-order-row">
<span class="pos-ex-order-main" id="ex-tp-text-{{ o.id }}">止盈:加载中…</span>
<button type="button" class="pos-ex-cancel-btn" id="ex-tp-cancel-{{ o.id }}" disabled onclick="cancelExchangeTpsl({{ o.id }}, 'tp')">撤单</button>
</div>
</div>
</div>
{% else %}
<div class="pos-empty">暂无持仓</div>
{% endfor %}
</div>
</div>
<div id="tpsl-modal" class="tpsl-modal-backdrop" onclick="if(event.target===this)closeTpslEntrustModal()">
<div class="tpsl-modal" onclick="event.stopPropagation()">
<h3 id="tpsl-modal-title">挂止盈止损</h3>
<p style="font-size:.78rem;color:#8892b0;margin:0 0 10px">将先撤销该合约已有 TP/SL,再按下列价格重挂。</p>
<div class="form-row">
<select id="tpsl-modal-mode" onchange="toggleTpslModalMode()">
<option value="price">价格模式</option>
<option value="pct">百分比模式</option>
</select>
</div>
<div class="form-row">
<input id="tpsl-modal-sl" step="any" placeholder="止损价格">
<input id="tpsl-modal-tp" step="any" placeholder="止盈价格">
</div>
<div class="form-row">
<input id="tpsl-modal-sl-pct" type="number" min="0.01" step="0.01" placeholder="止损%" style="display:none">
<input id="tpsl-modal-tp-pct" type="number" min="0.01" step="0.01" placeholder="止盈%" style="display:none">
</div>
<div class="tpsl-modal-actions">
<button type="button" class="tpsl-modal-cancel" onclick="closeTpslEntrustModal()">取消</button>
<button type="button" class="tpsl-modal-submit" onclick="submitTpslEntrust()">先撤后挂</button>
</div>
</div>
</div>
</div>
{% 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' %}
<div class="card full records-card">
<h2>交易记录 & 错过机会</h2>
<div class="form-row" style="margin-bottom:10px;gap:8px">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input id="review-mode-toggle" type="checkbox">
修改/核对开关(开启后可编辑关键字段)
</label>
</div>
<div class="table-wrap">
<table>
<tr><th>品种</th><th>类型</th><th>方向</th><th>成交</th><th>止损(开仓)</th><th>止盈</th><th>基数</th><th>杠杆</th><th>持仓分钟</th><th>开仓时间(北京)</th><th>平仓时间(北京)</th><th>盈亏U</th><th>结果</th><th>操作</th></tr>
{% for r in record %}
<tr id="trade-row-{{ r.id }}">
{% set pnl_val = (r.pnl_amount or 0)|float %}
<td>{{ r.symbol }}</td>
<td>{{ r.monitor_type }}{% if r.key_signal_type %} · {{ r.key_signal_type }}{% endif %}</td>
<td><span class="badge {{ 'direction-long' if r.direction == 'long' else 'direction-short' }}">{{ '做多' if r.direction == 'long' else '做空' }}</span></td>
<td>{{ price_fmt(r.symbol, r.trigger_price) }}</td>
{% 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 %}
<td>{{ price_fmt(r.symbol, stop_show) }}</td>
<td>{{ price_fmt(r.symbol, tp_show) }}</td>
<td>{% if r.margin_capital is not none and r.margin_capital != '' %}{{ funds_fmt(r.margin_capital) }}{% else %}-{% endif %}</td>
<td>{{ r.leverage or '-' }}</td>
<td>{{ r.effective_hold_minutes or 0 }}</td>
<td>{{ (r.effective_opened_at or '-')[:16] }}</td>
<td>{{ (r.effective_closed_at or r.created_at or '-')[:16] }}</td>
{% set pnl_val = (r.effective_pnl_amount or 0)|float %}
<td><span class="{{ 'pnl-profit' if pnl_val > 0 else ('pnl-loss' if pnl_val < 0 else '') }}">{{ funds_fmt(r.effective_pnl_amount or 0) }}</span>{% if r.display_pnl_source == 'exchange' %}<span style="font-size:.68rem;color:#6ab88a"></span>{% elif r.display_pnl_source != 'reviewed' %}<span style="font-size:.68rem;color:#8892b0"></span>{% endif %}</td>
<td>
{% set effective_result = r.effective_result %}
{% if effective_result in ["止盈","保本止盈","移动止盈"] %}<span class="badge profit">{{ effective_result }}</span>
{% elif effective_result in ["止损","强制清仓","手动平仓"] %}<span class="badge loss">{{ effective_result }}</span>
{% elif effective_result == "时间平仓" %}<span class="badge miss">{{ effective_result }}</span>
{% else %}<span class="badge miss">{{ effective_result }}</span>{% endif %}
</td>
<td>
<button
type="button"
class="table-del"
style="background:#1f3a5a;color:#8fc8ff;margin-right:6px"
onclick='fillJournalFromTrade({{ {
"symbol": r.symbol,
"monitor_type": r.monitor_type,
"key_signal_type": r.key_signal_type or "",
"direction": r.direction,
"trigger_price": r.trigger_price,
"stop_loss": r.display_open_stop_loss or r.initial_stop_loss or r.stop_loss,
"take_profit": r.effective_take_profit or r.take_profit,
"opened_at": r.effective_opened_at,
"closed_at": r.effective_closed_at,
"pnl_amount": r.effective_pnl_amount,
"result": r.effective_result,
"risk_amount": r.risk_amount
}|tojson|safe }})'
>填入复盘</button>
<button
type="button"
class="table-del review-edit-btn"
style="background:#1f3a5a;color:#8fc8ff;margin-right:6px"
onclick='editTradeRecordReview({{ {
"id": r.id,
"opened_at": r.effective_opened_at,
"closed_at": r.effective_closed_at,
"stop_loss": r.effective_stop_loss or r.initial_stop_loss or r.stop_loss,
"take_profit": r.effective_take_profit or r.take_profit,
"pnl_amount": r.effective_pnl_amount,
"result": r.effective_result,
"miss_reason": r.effective_miss_reason,
"effective_entry_reason": r.effective_entry_reason or ""
}|tojson|safe }})'
disabled
>核对修改</button>
<button type="button" class="table-del" onclick="deleteTradeRecord({{ r.id }})">删除</button>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="card miss-card" style="opacity:.78">
<h2>记录错过机会</h2>
<form action="/add_miss" method="post" class="form-row">
<input name="symbol" placeholder="品种" required>
<select name="type" required>
<option value="箱体突破">箱体突破</option>
<option value="收敛突破">收敛突破</option>
<option value="关键支撑阻力">关键支撑阻力</option>
</select>
<select name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
<input name="tp" step="0.0001" placeholder="入场价" required>
<input name="sl" step="any" placeholder="止损" required>
<input name="tgt" step="any" placeholder="止盈" required>
<input name="reason" placeholder="错过原因" required>
<button type="submit">记录</button>
</form>
</div>
<div class="card journal-card">
<h2>交易复盘记录上传(含截图)</h2>
<form id="journal-form" action="/add_journal" method="post" enctype="multipart/form-data">
<input type="hidden" name="risk_amount_hint" id="risk-amount-hint">
<input type="hidden" name="entry_price_hint" id="entry-price-hint">
<input type="hidden" name="stop_loss_hint" id="stop-loss-hint">
<input type="hidden" name="exit_price_hint" id="exit-price-hint">
<input type="hidden" name="direction_hint" id="direction-hint">
<div class="form-grid">
<input type="datetime-local" name="open_datetime" required>
<input type="datetime-local" name="close_datetime" required>
<input name="coin" placeholder="BTC" required>
<input name="tf" placeholder="5m" required>
<input name="pnl" placeholder="盈亏(U)" required>
<select name="entry_reason" id="journal-entry-reason" required title="固定五种或选其他手写">
<option value="">开仓类型(必选)</option>
{% for er in entry_reason_options %}
<option value="{{ er }}">{{ er }}</option>
{% endfor %}
<option value="{{ entry_reason_other_value }}">其他(自定义,见下方说明框)</option>
</select>
<input type="text" name="entry_reason_custom" id="journal-entry-reason-custom" maxlength="2000" placeholder="选「其他」时在此填写开仓类型说明" autocomplete="off" style="display:none">
<input name="expect_rr" placeholder="预期RR">
<input name="real_rr" placeholder="实际RR">
<select name="early_exit_trigger" required title="平仓如何触发">
<option value="">离场触发(必选)</option>
<option value="止盈">止盈</option>
<option value="保本止盈">保本止盈</option>
<option value="移动止盈">移动止盈</option>
<option value="时间平仓">时间平仓</option>
<option value="手动平仓">手动平仓</option>
<option value="止损">止损</option>
<option value="其他">其他</option>
</select>
<input name="early_exit_note" id="early-exit-note" placeholder="离场补充(仅手工平仓必填)">
<select name="post_breakeven_stare"><option value="否">保本后盯盘:否</option><option value="是">保本后盯盘:是</option></select>
<select name="new_trade_while_occupied"><option value="否">占用时新开仓:否</option><option value="是">占用时新开仓:是</option></select>
<input id="journal-screenshot" type="file" name="screenshot" accept="image/*">
</div>
<div class="form-row" style="margin-top:8px;flex-wrap:wrap;gap:10px;align-items:center">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem;color:#cfd3ef">
<input type="checkbox" name="journal_exchange_chart" value="true" checked>
保存时自动生成 K 线图并作为截图
</label>
<label style="font-size:.82rem;color:#9aa">周期1</label>
<select name="journal_chart_tf1" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">周期2</label>
<select name="journal_chart_tf2" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线数</label>
<select name="journal_chart_limit" style="min-width:72px">
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线截止</label>
<select name="journal_chart_anchor" id="journal-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select>
</div>
<div class="sub" id="journal-chart-anchor-hint" style="font-size:.72rem;color:#8892b0;margin-top:4px">双周期上下排列;截止=平仓时间:开仓前背景至平仓;截止=当前时间:最近 N 根至此刻(可看平仓后走势);标注开仓、平仓与止损位</div>
<div class="form-row" style="margin-top:8px">
<button type="button" style="background:#1f3a5a" onclick="prefillJournalByImage()">AI识别预填(你再手动改原因)</button>
</div>
<div class="mood-grid" style="margin-top:8px">
<label><input type="checkbox" name="mood_issues" value="怕踏空">怕踏空</label>
<label><input type="checkbox" name="mood_issues" value="报复开仓">报复开仓</label>
<label><input type="checkbox" name="mood_issues" value="盈利飘了">盈利飘了</label>
<label><input type="checkbox" name="mood_issues" value="拿不住单">拿不住单</label>
<label><input type="checkbox" name="mood_issues" value="扛单">扛单</label>
<label><input type="checkbox" name="mood_issues" value="重仓违规">重仓违规</label>
</div>
<textarea name="note" rows="2" style="width:100%;margin-top:8px" placeholder="备注"></textarea>
<button type="submit" style="margin-top:8px">保存复盘记录</button>
</form>
</div>
<div class="card full review-card" id="review-card">
<div class="review-card-head">
<h2>AI复盘(按交易记录)</h2>
<button type="button" class="review-card-fs-btn" id="review-card-fs-btn" onclick="toggleReviewCardFullscreen()">全屏</button>
</div>
<div class="form-row">
<input type="date" id="day_date">
<button type="button" id="gen-daily-btn" onclick="genDaily()">生成日复盘</button>
<button type="button" onclick="exportDailyBundleMd()" style="background:#1f3a5a">导出当日日复盘MD</button>
<input type="date" id="week_start">
<input type="date" id="week_end">
<button type="button" id="gen-weekly-btn" onclick="genWeekly()">生成周复盘</button>
<button type="button" onclick="exportWeeklyBundleMd()" style="background:#1f3a5a">导出当周复盘MD</button>
</div>
<div class="ai-result-wrap" id="daily_result_wrap" style="display:none">
<div id="daily_result" class="ai-result"></div>
<div class="ai-result-toolbar">
<button type="button" class="btn-fs" onclick="openAiInlineResultFullscreen('日复盘结果', 'daily_result')">全屏查看</button>
</div>
</div>
<div class="ai-result-wrap" id="weekly_result_wrap" style="display:none">
<div id="weekly_result" class="ai-result"></div>
<div class="ai-result-toolbar">
<button type="button" class="btn-fs" onclick="openAiInlineResultFullscreen('周复盘结果', 'weekly_result')">全屏查看</button>
</div>
</div>
<div class="panel-list" style="margin-top:10px">
<div class="panel-item">
<strong>交易复盘记录</strong>
<div id="journal-list"></div>
</div>
<div class="panel-item">
<strong>AI历史复盘</strong>
<div id="review-list"></div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% if page == 'stats' %}
<div class="card stats-card full" id="stats-card">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
<h2 style="margin-bottom:0">数据统计</h2>
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
</div>
<div class="stats-content" id="stats-content">
<div class="stat-box" style="margin-bottom:10px">
<div class="stat-item"><div class="label">持仓占用导致错过(累计)</div><div class="value">{{ occupied_miss_total }}</div></div>
</div>
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关)。历史总开仓(累计):
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong>
</div>
<div class="form-row" style="margin-bottom:14px;align-items:center">
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
统计品类
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
{% for seg in stats_bundle.segments %}
<option value="{{ seg.key }}">{{ seg.title }}</option>
{% endfor %}
</select>
</label>
</div>
{% for seg in stats_bundle.segments %}
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
{{ period_stats("日统计", seg.day) }}
{{ period_stats("周统计", seg.week) }}
{{ period_stats("月统计", seg.month) }}
</div>
{% endfor %}
</div>
</div>
{% endif %}
+120
View File
@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=17"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3">
<link rel="stylesheet" href="/static/instance_page.css?v=1">
<link rel="stylesheet" href="/static/instance_theme.css?v=18">
<script src="/static/account_risk_badge.js?v=3"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
</head>
<body
data-embed-shell="1"
data-page="{{ initial_tab }}"
data-balance-refresh-ms="{{ balance_refresh_seconds * 1000 }}"
data-price-refresh-ms="{{ price_refresh_seconds * 1000 }}"
>
<div class="container">
<div class="header">
<h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div>
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M12.1 3a9 9 0 1 0 8.9 11 6.5 6.5 0 1 1-8.9-11z"/>
</svg>
</button>
<button type="button" class="theme-toggle-btn" data-theme-value="light" aria-pressed="false" title="亮色主题">
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
</button>
</div>
</div>
</div>
<nav class="top-nav embed-top-nav" aria-label="实例导航">
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}">关键位监控</a>
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}">实盘下单</a>
<a href="/strategy" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if initial_tab == 'strategy_records' %}active{% endif %}">策略交易记录</a>
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
</nav>
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
<div class="list-window-bar">
<span style="color:#cfd3ef">列表筛选(<strong>UTC</strong>,默认当日):{{ list_window.label }}</span>
<label>预设
<select id="win-preset-select" onchange="toggleListWindowCustom()">
<option value="utc_today" {% if list_window.preset == 'utc_today' %}selected{% endif %}>UTC 当日</option>
<option value="utc_last24h" {% if list_window.preset == 'utc_last24h' %}selected{% endif %}>近 24 小时</option>
<option value="utc_last7d" {% if list_window.preset == 'utc_last7d' %}selected{% endif %}>近 7 天</option>
<option value="custom" {% if list_window.preset == 'custom' %}selected{% endif %}>自定义</option>
</select>
</label>
<span id="win-custom-range" style="{% if list_window.preset != 'custom' %}display:none{% endif %}">
<label>起(UTC) <input type="datetime-local" id="win-from-utc" value="{{ list_window.start_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
<label>止(UTC) <input type="datetime-local" id="win-to-utc" value="{{ list_window.end_utc.strftime('%Y-%m-%dT%H:%M') }}"></label>
</span>
<button type="button" style="padding:6px 12px" onclick="applyListWindow()">应用</button>
<span style="color:#8892b0;font-size:.75rem">统计页仍按北京时间 {{ stats_bundle.stats_reset_hour|default(reset_hour) }}:00 切日</span>
</div>
<div class="export-bar instance-desktop-only">
<span style="color:#9aa">数据导出(v{{ data_export_version }} CSVUTF-8;交易记录含开仓类型列,复盘单独导出):</span>
<a href="/export/trade_records">交易记录</a>
<a href="/export/journal_entries">复盘记录</a>
<a href="/export/key_monitors">关键位(当前)</a>
<a href="/export/key_monitor_history">关键位历史</a>
</div>
<div class="stat-box instance-desktop-only">
<div class="stat-item"><div class="label">交易所</div><div class="value">{{ exchange_display }}</div></div>
<div class="stat-item"><div class="label">总交易</div><div class="value" id="stat-total">{{ total }}</div></div>
<div class="stat-item"><div class="label">错过次数</div><div class="value" id="stat-miss">{{ miss_count }}</div></div>
<div class="stat-item"><div class="label">胜率</div><div class="value" id="stat-rate">{{ rate }}%</div></div>
<div class="stat-item"><div class="label">资金账户(USDT)</div><div class="value" id="total-capital">{% if funding_usdt is not none %}{{ funds_fmt(funding_usdt) }}U{% else %}—{% endif %}</div></div>
<div class="stat-item"><div class="label">交易日</div><div class="value">{{ trading_day }}</div></div>
<div class="stat-item"><div class="label">当日资金(交易账户)</div><div class="value" id="current-capital">{{ funds_fmt(current_capital) }}U</div></div>
</div>
{% if include_transfer_block %}
{% include 'gate_transfer_block.html' %}
{% endif %}
<div id="embed-page-root">
{% include 'embed_page_fragment.html' %}
</div>
</div>
<div class="modal" id="imgModal" onclick="closeModal()">
<img id="bigImg" src="" alt="screenshot">
</div>
<div class="detail-modal" id="detailModal" onclick="closeDetailModal(event)">
<div class="panel" onclick="event.stopPropagation()">
<div class="panel-head">
<div class="panel-title" id="detailTitle">详情</div>
<div class="panel-actions">
<button type="button" class="panel-fs" onclick="expandDetailToFullscreen()">全屏</button>
<button type="button" class="panel-close" onclick="forceCloseDetailModal()">关闭</button>
</div>
</div>
<div class="panel-body" id="detailBody"></div>
<img id="detailImage" class="panel-image" src="" alt="detail-image" style="display:none" onclick="showImage(this.src)">
</div>
</div>
<script src="/static/instance_ui.js?v=3"></script>
<script src="/static/instance_records_mobile.js?v=2"></script>
<script src="/static/time_close_ui.js?v=2"></script>
<script src="/static/ai_review_render.js?v=2"></script>
<script src="/static/form_submit_guard.js?v=2"></script>
<script src="/static/manual_order_rr_preview.js?v=3"></script>
<script src="/static/key_monitor_form.js?v=1"></script>
{% include 'embed_boot_scripts.html' %}
<script src="/static/instance_embed.js?v=1"></script>
</body>
</html>
+33 -16
View File
@@ -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")
+132
View File
@@ -0,0 +1,132 @@
"""中控 iframe:壳常驻 + tab 内容 API/embed、/api/embed/page/<tab>)。"""
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/<tab>")
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),
}
+33
View File
@@ -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,
'<div class="grid">',
*grid_inner,
"</div>",
*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))
+49
View File
@@ -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"<style>(.*?)</style>", text, re.S)
if m:
(ROOT / "static" / "instance_page.css").write_text(m.group(1).strip() + "\n", encoding="utf-8")
marker = '<script src="/static/manual_order_rr_preview.js?v=3"></script>'
if marker in text:
part = text.split(marker, 1)[1]
m2 = re.search(r"<script>(.*?)</script>\s*</body>", 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"<script>(.*?)</script>\s*</body>", 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(
"<script>\n" + boot_tpl + "\n</script>\n", encoding="utf-8"
)
print("done")
+231
View File
@@ -0,0 +1,231 @@
/**
* 中控 iframe 壳:顶栏/统计常驻,tab 内容走 /api/embed/page/<tab>。
*/
(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);
+220
View File
@@ -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}
+1
View File
@@ -406,6 +406,7 @@
/** 中控 iframefetch 换页 + 页内遮罩,避免整页卸载与中控侧长时间空白。 */
function initHubEmbedInFrameNav() {
if (!isHubLinked()) return;
if (document.body && document.body.getAttribute("data-embed-shell") === "1") return;
let navToken = 0;
+26
View File
@@ -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