Skip to main content

ek_ui/
render.rs

1//! 唯一渲染入口(L2):真实 eframe App 与 kittest 建构走同一个 `render_body`。
2//!
3//! 布局 v5(BD-UI-29 面板化,官方面板组合;蓝本 = Python 主窗 mainwindow.py):
4//! **TopBottomPanel(菜单) → TopBottomPanel(行情带) → SidePanel(下单侧) →
5//! CentralPanel(2×2 监控格) → Window 弹窗**,两带物理分离。
6//! - 行情带(TopBottomPanel::resizable = Python 分带 splitter 语义,缺省 240):
7//!   交易所二级 tab(只列有 tick 的所)+ tick 表(唯一源 = `eTick.`
8//!   订阅流,不从 bar 回);行内任意格单击 = 带入下单面板(ek 增值,方案钉;
9//!   盘口四列带方向:买1 价/量 → 卖,卖1 价/量 → 买,价格列顺带填价,其余列
10//!   方向保持默认——0.2.1 `import_tick`),行内任意格双击 = 弹 K线窗。
11//! - 下单侧(SidePanel::resizable,缺省 250):下单面板常驻(Python
12//!   TradingWidget 等价:9 字段 + 委托 + 全撤;表单 = 网格,按钮/提示语都是
13//!   网格行;网格后置 err label 由 ScrollArea 吸收 sizing 退让,BD-UI-27)+
14//!   盘口(买5卖5,Python TradingWidget 内序:字段钮 → 盘口)。
15//! - 2×2 监控格(CentralPanel 内,响应式分配,无定宽定高,BD-UI-29)
16//!   「委托|未成交」「成交」「持仓」「资金|日志」(撤单钮仅活动行,既有 s11
17//!   钉口径;委托行双击 = 撤单,Python OrderMonitor 等价;格内宽表走横向滚动,
18//!   D7 口径)。
19//! - 弹窗两式(BD-UI-32):运维窗(菜单「运维」:插件/服务/依赖;0.2.10 起
20//!   流控查询钮随 sim 退场撤除)、
21//!   CTP 配置窗(菜单「网关」,CTP 插件自己的界面)= egui::Window 依附主窗;
22//!   查询合约窗(菜单「查询合约」:查询 + 勾选 + 订阅钮,Python ContractManager
23//!   等价)、K线窗(双击弹出,按合约多开)= 独立 OS 窗(show_viewport_immediate,
24//!   原生最小化/最大化/关闭,可拖出主窗;后端不支持多窗自动回退 egui::Window,
25//!   kittest 走回退面断言)。
26//! - 订阅两入口(用户裁定,网关窗不定死订阅):下单面板代码框**回车**订阅
27//!   (Python returnPressed 等价);查询合约窗**勾选订阅**。合约数据 =
28//!   `eContract.` 自动回流(ek-ctp 登录后自动查询,UI 只收不催)。
29//! - 回灌表单与票据文本框已退役(界面面);总线口(`UiCmd::Reload` 等)保留。
30//!
31//! kittest 断言面:各单元格独立 Label(`get_by_label` 直查);操作走
32//! `push_cmd` 入队(ui 插件 eTimer 辞行出队),确认状态就地翻位,零自制通道。
33//! 隔离红标:行状态含 Quarantin(快照扫描,BD-UI-17)。
34
35use ek_data::constant::{Direction, Offset, OrderType};
36use ek_station::wire::PlugRow;
37
38use crate::state::{
39    BottomRightTab, CellView, ConfirmStep, TopLeftTab, UiCmd, UiShared, is_quarantined,
40};
41
42/// 清空日志按钮的唯一可访问性标签(测试与 UI 同源)。
43pub const BTN_CLEAR_LOGS: &str = "清空日志";
44/// 取消待确认按钮标签。
45pub const BTN_CANCEL_CONFIRM: &str = "取消确认";
46/// 下单提交按钮标签(v4 = Python「委托」钮)。
47pub const BTN_SEND_ORDER: &str = "委托";
48/// 全撤按钮标签(v4;活动委托逐个走既有撤单 ask)。
49pub const BTN_CANCEL_ALL: &str = "全撤";
50/// 撤单按钮前缀(S5;行级标签 = `撤单 {orderid}`)。
51pub const BTN_CANCEL_ORDER: &str = "撤单";
52/// 账户面板:添加账户钮(派生注册名 → svctl.start + {gw}.configure)。
53pub const BTN_ACCOUNT_ADD: &str = "添加账户";
54/// 菜单项:运维(弹运维窗)。
55pub const MENU_OPS: &str = "运维";
56/// 菜单项:账户(弹账户面板;多账号面入口,规划 §5.B。旧「网关」单账号窗
57/// 已退役收纳进账户面板的添加/编辑表单,旧常量随之删除)。
58pub const MENU_ACCOUNTS: &str = "账户";
59/// 菜单项:帮助。
60pub const MENU_HELP: &str = "帮助";
61/// 菜单项:查询合约(弹查询合约窗;Python ContractManager 等价)。
62pub const MENU_CONTRACTS: &str = "查询合约";
63/// 菜单项 → 弹窗入口钮(BD-UI-31 一键直达:菜单钮即入口,一步弹窗,
64/// 不再挂下拉层;入口钮标签 = 菜单名本身)。
65/// 查询合约窗订阅钮(勾选集 → ask ctp.subscribe)。
66pub const BTN_SUBSCRIBE_SELECTED: &str = "订阅选中";
67
68/// F4 口径句(0.2.20,D-UI-F4-SCOPE):账户面板底部与配置弹窗**共用这一条常量**。
69/// 两处各写一遍字面量 = 将来不同式的坑(s57 数的是"两处都在")。
70pub const TXT_PRODUCT_CLASS_SCOPE: &str = "收录勾选只管该网关自己收录并上抛的合约;公用合约表 = 在场各网关的并集(合约与行情公用,规划 D3)。改勾选须『连接/重试』才下发到网关。";
71
72fn remove_button_label(name: &str, armed_on: Option<&str>) -> String {
73    if armed_on == Some(name) {
74        format!("确认移除 {name} ?")
75    } else {
76        format!("移除 {name}")
77    }
78}
79
80/// 订阅路由(规划 §5.C):行情订阅一律发往**行情元账号**。
81///
82/// 未指定行情源时回 `Err` —— 不静默发给某个从账号(纯 TD 账号收到订阅会
83/// 恒 `ok:true` 但永远不来 tick,是观测黑洞)。
84fn subscribe_target(shared: &UiShared) -> Result<String, String> {
85    let md = shared.md_source();
86    if md.is_empty() {
87        return Err("未指定行情源:账户面板点某账户的『设为行情源』后再订阅".to_string());
88    }
89    Ok(md)
90}
91
92fn state_text(r: &PlugRow) -> String {
93    r.state.clone()
94}
95
96/// DtNs → HH:MM:SS(**北京时间 +08**;数据面时间戳统一存 UTC 纳秒 —— CTP
97/// 回报的自然日本就是北京钟面,入账按 +08 换算存 UTC,展示面换回来,表内
98/// 时间列与交易员作息同钟面)。
99fn fmt_dt(dt: Option<i64>) -> String {
100    dt.map(|ns| {
101        chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(ns)
102            .with_timezone(&beijing_tz())
103            .format("%H:%M:%S")
104            .to_string()
105    })
106    .unwrap_or_else(|| "-".to_string())
107}
108
109/// 展示面统一时区:UTC+08(固定偏移,不依赖本机时区设置)。
110fn beijing_tz() -> chrono::FixedOffset {
111    chrono::FixedOffset::east_opt(8 * 3600).expect("+08 offset is always valid")
112}
113
114// ---- BD-UI-31(R3)行高亮:悬停淡底 / 选中跳底 ----
115
116/// 行底色绘制:行内各格响应矩形并集 → 选中 = 跳底(蓝调);否则悬停 = 淡灰底。
117/// 半透明叠底(行文字先画,叠后仍可读);行交互钮单击选中与双击动作并存
118/// (egui 双击先经 clicked,选中顺带发生,无冲突)。
119fn paint_row_bg(ui: &egui::Ui, cell_rects: &[egui::Rect], selected: bool) {
120    let Some(union) = cell_rects.iter().copied().reduce(|a, b| a.union(b)) else {
121        return;
122    };
123    let hovered = ui
124        .input(|i| i.pointer.interact_pos())
125        .is_some_and(|p| union.expand(3.0).contains(p));
126    if selected {
127        ui.painter().rect_filled(
128            union.expand(3.0),
129            3.0,
130            egui::Color32::from_rgba_unmultiplied(64, 108, 180, 82),
131        );
132    } else if hovered {
133        ui.painter().rect_filled(
134            union.expand(3.0),
135            3.0,
136            egui::Color32::from_rgba_unmultiplied(128, 128, 128, 38),
137        );
138    }
139}
140
141/// 委托是否可撤(与 ek-data ACTIVE_STATUSES 同口径)。
142fn order_active(status: ek_data::constant::Status) -> bool {
143    ek_data::constant::ACTIVE_STATUSES.contains(&status)
144}
145
146/// 格可点化(BD-UI-31 扩展):标签外观不变,在标签矩形上叠一层点击感应 ——
147/// 行内任何一格都可单击/双击(用户裁定:动作归行,不归某一列)。
148fn click_cell(ui: &egui::Ui, rect: egui::Rect, id_salt: impl std::hash::Hash) -> egui::Response {
149    ui.interact(rect, ui.id().with(id_salt), egui::Sense::click())
150}
151
152/// 行情带入(0.2.1 扩展):任意列单击 = 带入合约+交易所;盘口四列带方向
153/// (买1价/买1量 → 卖,卖1价/卖1量 → 买 —— 吃对手挂价口径),两个价格列
154/// 顺带把该价填进价格格;其余列方向/价格保持表单默认(用户裁定)。
155pub fn import_tick(shared: &UiShared, t: &ek_data::tick::TickData, exchange: &str, col: usize) {
156    let mut f = shared.order_form();
157    f.symbol = t.symbol.clone();
158    f.exchange = exchange.to_string();
159    match col {
160        8 => {
161            f.direction = Direction::SHORT;
162            f.price = format!("{:.1}", t.bid_price_1);
163        }
164        9 => f.direction = Direction::SHORT,
165        10 => {
166            f.direction = Direction::LONG;
167            f.price = format!("{:.1}", t.ask_price_1);
168        }
169        11 => f.direction = Direction::LONG,
170        _ => {}
171    }
172    shared.set_order_form(f);
173}
174
175/// 按合约找最新 tick(下单面板名称/盘口/随价共用)。
176/// 交易所非空 = 精确 EX.symbol 匹配(跨所同码不串);空 = 仅按代码找。
177fn tick_of(shared: &UiShared, exchange: &str, symbol: &str) -> Option<ek_data::tick::TickData> {
178    if symbol.is_empty() {
179        return None;
180    }
181    shared.tick_rows().into_iter().find(|t| {
182        t.symbol == symbol
183            && (exchange.is_empty() || t.exchange.as_str().eq_ignore_ascii_case(exchange))
184    })
185}
186
187/// 全局字号(BD-UI-32 R1,用户实看裁定「整体字偏小」,对照 Python 版):
188/// body/button/strong 16px、small 14px、heading 22px、monospace 15px
189/// (egui 缺省 13/12/18/12)。render_body 顶部统一施加,真实 App 与 kittest 同源。
190pub fn apply_ui_style(ctx: &egui::Context) {
191    apply_ui_style_for(ctx, crate::state::Theme::default());
192}
193
194/// 字号 + 主题视觉(BD-UI-34,用户问「PySide6 式可换主题」):主题 = UiShared
195/// 运行期态(菜单钮一键循环),视觉在此统一施加——真实 App、独立子窗
196/// (immediate 共享同一 Context)、kittest 三处同源。语义色(多红/空绿/
197/// 隔离红/选中蓝)不在此列:渲染侧恒定直绘,跨主题不漂移(金融语义)。
198pub fn apply_ui_style_for(ctx: &egui::Context, theme: crate::state::Theme) {
199    ctx.style_mut(|style| {
200        style
201            .text_styles
202            .insert(egui::TextStyle::Body, egui::FontId::proportional(16.0));
203        style
204            .text_styles
205            .insert(egui::TextStyle::Button, egui::FontId::proportional(16.0));
206        style
207            .text_styles
208            .insert(egui::TextStyle::Small, egui::FontId::proportional(14.0));
209        style
210            .text_styles
211            .insert(egui::TextStyle::Heading, egui::FontId::proportional(22.0));
212        style
213            .text_styles
214            .insert(egui::TextStyle::Monospace, egui::FontId::monospace(15.0));
215    });
216    ctx.set_visuals(theme_visuals(theme));
217}
218
219/// 五套主题视觉(BD-UI-34;0.2.26 加 BD-UI-40 的 Qt深色)。金融深色 = egui dark
220/// 骨架换深海军蓝底(交易终端观感,对照 Python 版深色窗);Qt 灰白 = PySide6
221/// Fusion 迁移用;Qt深色 = qdarkstyle 3.2.3 复刻(见下)。
222fn theme_visuals(t: crate::state::Theme) -> egui::Visuals {
223    use egui::Color32;
224    match t {
225        crate::state::Theme::EguiDark => egui::Visuals::dark(),
226        crate::state::Theme::EguiLight => egui::Visuals::light(),
227        crate::state::Theme::QtClassic => {
228            let mut v = egui::Visuals::light();
229            v.window_fill = Color32::from_rgb(0xF6, 0xF6, 0xF6);
230            v.panel_fill = Color32::from_rgb(0xEF, 0xEF, 0xEF);
231            v.extreme_bg_color = Color32::from_rgb(0xFF, 0xFF, 0xFF);
232            v.faint_bg_color = Color32::from_rgb(0xE4, 0xE4, 0xE4);
233            v.selection.bg_fill = Color32::from_rgb(0xC8, 0xDC, 0xF0);
234            v
235        }
236        crate::state::Theme::FinanceDark => {
237            let mut v = egui::Visuals::dark();
238            v.window_fill = Color32::from_rgb(0x18, 0x20, 0x2C);
239            v.panel_fill = Color32::from_rgb(0x14, 0x1A, 0x24);
240            v.extreme_bg_color = Color32::from_rgb(0x0C, 0x11, 0x18);
241            v.faint_bg_color = Color32::from_rgb(0x10, 0x15, 0x1E);
242            v.selection.bg_fill = Color32::from_rgb(0x2A, 0x44, 0x68);
243            v.hyperlink_color = Color32::from_rgb(0x6A, 0x9C, 0xD8);
244            v.widgets.noninteractive.bg_stroke =
245                egui::Stroke::new(1.0, Color32::from_rgb(0x3A, 0x46, 0x58));
246            v
247        }
248        // Qt深色(BD-UI-40,0.2.26):复刻 Python/PySide6 版的黑色主题。
249        // 依据 = 已安装的 QDarkStyle 3.2.3:`dark/palette.py`(色板)+ `dark/darkstyle.qss`
250        // (规格,2216 行实测:radius 4px 出现 48 次、描边 1px、padding 以 2px 为主)。
251        // 源:`enkeep_trade/enkeep_trade/trader/ui/qt.py:102`
252        //   `qapp.setStyleSheet(qdarkstyle.load_stylesheet(qt_api="pyside6"))`
253        // 映射口径(逐条对过 qss,不是凭印象):
254        //   QWidget/QLabel/QLineEdit/表格 底 = BG1 #19232D —— qdarkstyle 所有容器同底,
255        //     靠 1px BG4 描边区分,所以 window_fill 与 panel_fill 同值(不是偷懒,是照抄)。
256        //   QPushButton #455364 → hover #54687A → pressed/checked #60798B,且 `border: none`
257        //     → bg_stroke 与 bg_fill 取同色(画出来就是无边)。
258        //   expansion 一律 0.0:qdarkstyle hover 只换色不涨块,egui 缺省 hover/active 涨 1px。
259        //   fg_stroke 宽度一律 1.0:Qt hover/pressed 不加重文字,egui 缺省 1.5/2.0。
260        //   选中行与选中文本 = #346792(qss `QWidget::item:selected` 与 `QLineEdit:selected`
261        //     同值);qdarkstyle 的选中 tab 是「#54687A 底 + 3px #259AE9 下划线」,egui 只有
262        //     一个 selection.bg_fill 且 selectable_label 只能整块填充 → 取强调色整块代替,
263        //     下划线这条在 DESIGN 里记成已知偏离。
264        //   selection.stroke.color 会被 egui 当**选中项文字色**用(style.rs:374)→ 取
265        //     TEXT1 #DFE1E2(qss 选中项文字正是它);若照抄 qss 的 focus 边框 #1A72BB,
266        //     选中 tab 的文字会变蓝。
267        crate::state::Theme::QDarkStyle => {
268            // qdarkstyle 3.2.3 实测色板(colorsystem.py 原值)。
269            let bg1 = Color32::from_rgb(0x19, 0x23, 0x2D); // Gray.B10 窗/容器底
270            let bg2 = Color32::from_rgb(0x29, 0x35, 0x44); // Gray.B20 「比背景差一点」那档
271            let bg4 = Color32::from_rgb(0x45, 0x53, 0x64); // Gray.B40 按钮底 + 一切描边
272            let bg5 = Color32::from_rgb(0x54, 0x68, 0x7A); // Gray.B50 hover
273            let bg6 = Color32::from_rgb(0x60, 0x79, 0x8B); // Gray.B60 pressed/checked
274            let text1 = Color32::from_rgb(0xDF, 0xE1, 0xE2); // Gray.B130 正文/按钮文字
275            let text3 = Color32::from_rgb(0xAC, 0xB1, 0xB6); // Gray.B90 次级文本
276            let accent2 = Color32::from_rgb(0x34, 0x67, 0x92); // Blue.B40 选中/进度 chunk
277            let accent4 = Color32::from_rgb(0x25, 0x9A, 0xE9); // Blue.B70 选中 tab 下划线那支蓝
278            let r4 = egui::CornerRadius::same(4); // qss 里 border-radius 唯一值(48 处)
279
280            let mut v = egui::Visuals::dark();
281            v.dark_mode = true;
282            v.window_fill = bg1;
283            v.panel_fill = bg1;
284            v.extreme_bg_color = bg1; // QLineEdit 底 = 窗底(qss 正是同值)
285            v.faint_bg_color = bg2;
286            v.hyperlink_color = accent4;
287            v.weak_text_color = Some(text3);
288            v.window_stroke = egui::Stroke::new(1.0, bg4);
289            v.window_corner_radius = r4;
290            v.menu_corner_radius = r4;
291            // 不设(有意,理由记在 DESIGN BD-UI-40 的「多余」条):text_edit_bg_color(缺省
292            // 跟随 extreme_bg_color,同值设两遍是重复)、code_bg_color(本 UI 的
293            // ui.monospace 只换字体不带背景)、warn/error_fg_color(语义色在渲染侧直绘、
294            // 跨主题恒定)、slider/progress 相关(本 UI 0 处 slider、0 处进度条)。
295
296            let stroke1 = egui::Stroke::new(1.0, text1);
297            // 注:`WidgetVisuals` / `Selection` 不在 egui 的根 re-export 里
298            // (lib.rs:508 只出 `Style/TextStyle/Visuals/Spacing`),走 `egui::style::` 全路径。
299            v.widgets.noninteractive = egui::style::WidgetVisuals {
300                bg_fill: bg1,
301                weak_bg_fill: bg1,
302                bg_stroke: egui::Stroke::new(1.0, bg4), // 分隔线/窗框 = qss 那支 #455364
303                corner_radius: r4,
304                fg_stroke: stroke1,
305                expansion: 0.0,
306            };
307            v.widgets.inactive = egui::style::WidgetVisuals {
308                bg_fill: bg4,
309                weak_bg_fill: bg4,
310                bg_stroke: egui::Stroke::new(1.0, bg4), // 与填充同色 = 视觉无边
311                corner_radius: r4,
312                fg_stroke: stroke1,
313                expansion: 0.0,
314            };
315            v.widgets.hovered = egui::style::WidgetVisuals {
316                bg_fill: bg5,
317                weak_bg_fill: bg5,
318                bg_stroke: egui::Stroke::new(1.0, bg5),
319                corner_radius: r4,
320                fg_stroke: stroke1,
321                expansion: 0.0,
322            };
323            v.widgets.active = egui::style::WidgetVisuals {
324                bg_fill: bg6,
325                weak_bg_fill: bg6,
326                bg_stroke: egui::Stroke::new(1.0, bg6),
327                corner_radius: r4,
328                fg_stroke: stroke1,
329                expansion: 0.0,
330            };
331            v.widgets.open = egui::style::WidgetVisuals {
332                bg_fill: bg6,
333                weak_bg_fill: bg6,
334                bg_stroke: egui::Stroke::new(1.0, bg4),
335                corner_radius: r4,
336                fg_stroke: stroke1,
337                expansion: 0.0,
338            };
339            v.selection = egui::style::Selection {
340                bg_fill: accent2,
341                stroke: egui::Stroke::new(1.0, text1), // 这是「选中项文字色」,不是边框色
342            };
343            // Qt 的按钮/链接是手型光标;egui 缺省 None(浏览器口径)。qdarkstyle 走 Qt 原生。
344            v.interact_cursor = Some(egui::CursorIcon::PointingHand);
345            v
346        }
347    }
348}
349
350/// 画一窗(官方面板组合:render_body 直接开顶层面板,不自嵌 CentralPanel)。
351///
352/// v5 框架(BD-UI-29 面板化):TopBottomPanel(菜单) → TopBottomPanel(行情带,
353/// resizable = Python 分带 splitter 语义) → SidePanel(下单面板 + 盘口,
354/// resizable) → CentralPanel(2×2 监控格,响应式分配,必须最后) → 弹窗。
355/// 官方纪律(panel.rs 模块文档):顶层面板不得互相嵌套、CentralPanel 最后加、
356/// Window 挂在所有面板之后。
357pub fn render_body(ctx: &egui::Context, shared: &UiShared) {
358    apply_ui_style_for(ctx, shared.theme());
359    // ---- 菜单栏(顶层面板,最先 = 最外) ----
360    egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
361        render_menu_bar(ui, shared);
362    });
363
364    // ---- 上带:行情带(全宽独占;可拖分隔条,缺省 240 ≈ Python 30:70 分带) ----
365    egui::TopBottomPanel::top("market_band")
366        .resizable(true)
367        .default_height(240.0)
368        .show(ctx, |ui| {
369            // 外层 ScrollArea auto_shrink(false) = resizable 面板吃满前提(BD-UI-27
370            // 口径;s3 实锤:撤掉后空态带塌成一行,2×2 行位全漂移)。内层
371            // pinned_table 自己会收缩适配 → 外层滚动条永不出现。
372            egui::ScrollArea::both()
373                .id_salt("market_band_scroll")
374                .auto_shrink([false, false])
375                .show(ui, |ui| {
376                    render_market_band(ui, shared);
377                });
378        });
379
380    // ---- 下带左侧:下单面板 + 盘口(Python TradingWidget 内序:字段钮 → 盘口) ----
381    egui::SidePanel::left("order_side")
382        .resizable(true)
383        .default_width(250.0)
384        .show(ctx, |ui| {
385            // ScrollArea 吸收 sizing 退让(BD-UI-27):网格后置 err label 不再叠回网格
386            egui::ScrollArea::vertical()
387                .id_salt("order_side_scroll")
388                .auto_shrink([false, false])
389                .show(ui, |ui| {
390                    render_order_panel(ui, shared);
391                    ui.separator();
392                    render_book_frame(ui, shared);
393                });
394        });
395
396    // ---- 下带右侧:2×2 监控格(CentralPanel 最后;响应式分配,无定宽定高) ----
397    egui::CentralPanel::default().show(ctx, |ui| {
398        let avail = ui.available_size();
399        let sp = ui.spacing().item_spacing;
400        // 机制钉(dbg 实锤):horizontal 行成员的可用高度初始只有一行高
401        // (interact_size),高度分配不沿横向向下传播 → 定高 max_height 失效、
402        // 行按内容收缩(v4 死区根因)。官方解法 = 先精确预留矩形(group_frame:
403        // allocate_exact_size + new_child;add_sized 收 impl Widget 不适用),
404        // Frame+ScrollArea 在预留矩形内才吃满。
405        let gw = ((avail.x - sp.x) / 2.0).max(220.0);
406        let gh = ((avail.y - sp.y) / 2.0).max(120.0);
407        ui.horizontal(|ui| {
408            group_frame_plain(ui, "grp_topleft", gw, gh, |ui| {
409                render_topleft_group(ui, shared);
410            });
411            group_frame_plain(ui, "grp_trades", gw, gh, |ui| {
412                render_trades_group(ui, shared);
413            });
414        });
415        ui.horizontal(|ui| {
416            group_frame_plain(ui, "grp_positions", gw, gh, |ui| {
417                render_positions_group(ui, shared);
418            });
419            // 资金/日志格:保留带填充 ScrollArea 的容器(0.2.6 钉):此格无
420            // pinned_table,plain Frame 会收缩到内容宽(实测 810..1007)→ 右边框
421            // 落在行中段 = 又一根「多出来的竖线」。外层 ScrollArea
422            // auto_shrink(false) 让 Frame 吃满 gw,边界回到格位。
423            group_frame(ui, "grp_bottomright", gw, gh, |ui| {
424                render_bottomright_group(ui, shared);
425            });
426        });
427    });
428
429    // ---- 弹窗面(官方口径:Window 在顶层面板之后) ----
430    render_windows(ctx, shared);
431}
432
433/// 2×2 格统一容器(响应式尺寸 + 双向滚动,BD-UI-27 可达性口径延续)。
434/// 尺寸钉(BD-UI-29):`allocate_exact_size` 精确预留 w×h 矩形 + `new_child`
435/// 在预留矩形内开 Frame+ScrollArea(dbg 实锤:horizontal 行成员的可用高度
436/// 初始只有一行高,高度分配不沿横向向下传播 → 定高 max_height 失效、行按
437/// 内容收缩 = v4 死区根因;先预留矩形,可用空间才吃满)。
438/// 布局泄漏钉(第五轮审计):Frame::show 继承父级布局 → 格内内容强制
439/// `ui.vertical`(短行成行,宽网格末位溢出走横向滚动)。
440fn group_frame(ui: &mut egui::Ui, id: &str, w: f32, h: f32, content: impl FnOnce(&mut egui::Ui)) {
441    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
442    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
443    egui::Frame::group(ui.style()).show(&mut child, |ui| {
444        egui::ScrollArea::both()
445            .id_salt(id)
446            .auto_shrink([false, false])
447            .show(ui, |ui| {
448                ui.vertical(content);
449            });
450    });
451}
452
453/// 表格格容器(BD-UI-34):预留矩形 + Frame,但**不开外层 ScrollArea**——
454/// 滚动归 pinned_table 自己(表头固定 + 体滚 + 横滚同移),外层再包滚动
455/// = 双滚动条且表头失去固定意义。
456/// 吃满钉(0.2.6):Frame::show 的描边矩形按内容 min_rect 收缩(dbg 实锤:
457/// 内容换成一行 label 时 Frame 从 gw 宽缩到 112)——本容器只配 pinned_table
458/// (auto_shrink(false) 滚动区 = 不收缩填充物)使用;非表格内容走 group_frame。
459fn group_frame_plain(
460    ui: &mut egui::Ui,
461    _id: &str,
462    w: f32,
463    h: f32,
464    content: impl FnOnce(&mut egui::Ui),
465) {
466    let (rect, _resp) = ui.allocate_exact_size(egui::vec2(w, h), egui::Sense::hover());
467    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
468    egui::Frame::group(ui.style()).show(&mut child, |ui| {
469        ui.vertical(content);
470    });
471}
472
473/// 表头固定表(BD-UI-34,用户裁定「每个表的表头都要固定」):
474/// - 单 grid 实例(头行 + 体行)在 both ScrollArea 内——列对齐天然、无同 id
475///   双 grid 病(dbg 实锤:两 grid 同 id 各从空白重建 State,save 交替
476///   request_repaint → kittest max_steps 爆)。
477/// - 表头固定 = 官方 DIY 模式(scroll_area.rs:412「painting a sticky header
478///   on top of it」):ScrollArea 之后,用本拍捕获的表头格矩形在滚动区顶部
479///   重画一条表头带(背景盖行 + 文字重画)。格矩形已含滚动平移 → 横向自动
480///   跟随、纵向永远盖顶,零拍滞后。带高 = 表头格高 + 行间距(0.2.6 修:只盖
481///   格高时,滚动行从行间距带里露 3px = 用户报「表头缝露内容」)。
482/// - 覆盖带 = 纯 painter 绘制(0.2.6 修,用户报「资金右多一根竖线」根因):
483///   旧版 ui.put 重画视口外表头格(16 列总宽撑到 x=1222),布局 min_rect
484///   被撑宽 → Frame 描边矩形跟着撑 → 格右边界画在内容中段。painter.text/
485///   rect_filled 零布局副作用;越界文字由 clip 裁掉。
486///
487/// body 闭包在体表 grid 内执行(行循环 + end_row 归闭包)。
488fn pinned_table(
489    ui: &mut egui::Ui,
490    grid_id: &str,
491    num_columns: usize,
492    headers: &[&str],
493    body: impl FnOnce(&mut egui::Ui),
494) {
495    let heads_cell: std::cell::RefCell<Vec<(egui::Rect, String)>> =
496        std::cell::RefCell::new(Vec::new());
497    let resp = egui::ScrollArea::both()
498        .id_salt(format!("{grid_id}_sc"))
499        .auto_shrink([false, false])
500        .show(ui, |ui| {
501            egui::Grid::new(grid_id)
502                .striped(true)
503                .num_columns(num_columns)
504                .show(ui, |ui| {
505                    for h in headers {
506                        let r = ui.strong(*h);
507                        heads_cell.borrow_mut().push((r.rect, h.to_string()));
508                    }
509                    ui.end_row();
510                    body(ui);
511                });
512        });
513    let heads = heads_cell.borrow();
514    if heads.is_empty() {
515        return;
516    }
517    let area = resp.inner_rect;
518    let head_h = heads[0].0.height();
519    // 覆盖带几何(0.2.8,BD-UI-35 缝隙根因):ScrollArea 内容裁剪 = inner_rect
520    // 向外扩 visuals().clip_rect_margin(默认 3.0,style.rs:1435;
521    // scroll_area.rs:771-772)——带子若只从 inner_rect 顶边起画,滚动行会从
522    // 带子上方 3px 余量里露出(用户报「表头上面有缝」)。带子向上/左右各扩
523    // 该余量,向下盖 表头格高 + grid 行间距。clip 同步 = 带矩形。
524    let m = ui.visuals().clip_rect_margin;
525    let strip = egui::Rect::from_min_max(
526        egui::pos2(area.min.x - m, area.min.y - m),
527        egui::pos2(
528            area.max.x + m,
529            area.min.y + head_h + ui.spacing().item_spacing.y,
530        ),
531    );
532    ui.scope_builder(
533        egui::UiBuilder::new()
534            .id_salt(format!("{grid_id}_hdr_ov"))
535            .max_rect(strip),
536        |ui| {
537            ui.set_clip_rect(strip);
538            let painter = ui.painter();
539            painter.rect_filled(strip, 0.0, ui.visuals().panel_fill);
540            let font = ui.style().text_styles[&egui::TextStyle::Body].clone();
541            let color = ui.visuals().strong_text_color();
542            for (rect, text) in heads.iter() {
543                painter.text(
544                    egui::pos2(rect.min.x, area.min.y + head_h * 0.5),
545                    egui::Align2::LEFT_CENTER,
546                    text.as_str(),
547                    font.clone(),
548                    color,
549                );
550            }
551        },
552    );
553}
554
555// ================= 菜单栏 =================
556
557fn render_menu_bar(ui: &mut egui::Ui, shared: &UiShared) {
558    ui.horizontal(|ui| {
559        // BD-UI-31(R1):一键直达 —— 菜单钮即入口,单击直接弹窗(去下拉层)。
560        if ui.button(MENU_OPS).clicked() {
561            shared.set_ops_window_open(true);
562        }
563        if ui.button(MENU_ACCOUNTS).clicked() {
564            shared.set_accounts_window_open(true);
565        }
566        if ui.button(MENU_CONTRACTS).clicked() {
567            shared.set_contracts_window_open(true);
568        }
569        ui.menu_button(MENU_HELP, |ui| {
570            ui.label(format!("ek-station 观测台 v{}", env!("CARGO_PKG_VERSION")));
571            ui.label("布局 = Python 主窗框架等价(行情带/交易带/弹窗)");
572        });
573        // BD-UI-34:主题一键循环(金融深色→egui深色→egui浅色→Qt灰白→…;
574        // 单层钮无下拉,与 R1「菜单钮即入口」同口径)。
575        if ui
576            .button(format!("主题:{}", shared.theme().label()))
577            .clicked()
578        {
579            let t = shared.cycle_theme();
580            shared.log_line(format!("[ops] 主题切换 → {}", t.label()));
581        }
582    });
583}
584
585// ================= 行情带 =================
586
587fn render_market_band(ui: &mut egui::Ui, shared: &UiShared) {
588    // BD-UI-31(R4):表题文字撤除(带位即身份,Python TickMonitor 无题同口径)。
589    let groups = shared.tick_groups();
590    if groups.is_empty() {
591        ui.label("(等待 eTick 订阅)");
592        return;
593    }
594    // 二级 tab:只列有 tick 的所
595    let sel = shared.selected_exchange();
596    let active = if groups.iter().any(|(e, _)| *e == sel) {
597        sel.clone()
598    } else {
599        groups[0].0.clone()
600    };
601    ui.horizontal(|ui| {
602        for (ex, _) in &groups {
603            if ui.selectable_label(*ex == active, ex.clone()).clicked() {
604                shared.set_selected_exchange(ex.clone());
605            }
606        }
607    });
608    let ticks = groups
609        .iter()
610        .find(|(e, _)| *e == active)
611        .map(|(_, v)| v.clone())
612        .unwrap_or_default();
613    // 状态行「tick N 格 / 最后更新 …」撤除(BD-UI-35,用户裁定:此信息早已
614    // 不需要,行情带 tab + 表格即全部)。
615    pinned_table(
616        ui,
617        "tick_grid",
618        14,
619        &[
620            "代码",
621            "名称",
622            "最新价",
623            "涨跌幅",
624            "成交量",
625            "今开",
626            "最高",
627            "最低",
628            "买1价",
629            "买1量",
630            "卖1价",
631            "卖1量",
632            "时间",
633            "行情源",
634        ],
635        |ui| {
636            for t in &ticks {
637                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 带入下单面板
638                // (盘口四列带方向:买1价/买1量 → 卖,卖1价/卖1量 → 买,价格列
639                // 顺带填价;其余列方向/价格保持默认——用户裁定);任何一格双击
640                // = 弹 K线窗。选中行 = 下单面板当前合约(带入即跳色)。
641                let row_sel = shared.order_form().symbol == t.symbol;
642                let mut cells = Vec::with_capacity(14);
643                let mut dbl = false;
644                let resp = ui.button(t.symbol.clone());
645                if resp.clicked() {
646                    shared.set_selected_exchange(active.clone());
647                    import_tick(shared, t, &active, 0);
648                }
649                dbl |= resp.double_clicked();
650                cells.push(resp.rect);
651                macro_rules! cell {
652                    ($base:expr, $col:expr) => {{
653                        let base = $base;
654                        let r = click_cell(ui, base.rect, ("tick", &t.symbol, $col));
655                        if r.clicked() {
656                            shared.set_selected_exchange(active.clone());
657                            import_tick(shared, t, &active, $col);
658                        }
659                        dbl |= r.double_clicked();
660                        cells.push(base.rect);
661                    }};
662                }
663                cell!(
664                    ui.label(if t.name.is_empty() {
665                        "-".to_string()
666                    } else {
667                        t.name.clone()
668                    }),
669                    1
670                );
671                cell!(ui.label(format!("{:.1}", t.last_price)), 2);
672                cell!(
673                    ui.label(egui::RichText::new(format!("{:+.2}", t.pct_change)).color(
674                        if t.pct_change < 0.0 {
675                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
676                        } else {
677                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
678                        },
679                    )),
680                    3
681                );
682                cell!(ui.label(format!("{:.0}", t.volume)), 4);
683                cell!(ui.label(format!("{:.1}", t.open_price)), 5);
684                cell!(ui.label(format!("{:.1}", t.high_price)), 6);
685                cell!(ui.label(format!("{:.1}", t.low_price)), 7);
686                cell!(
687                    ui.label(
688                        egui::RichText::new(format!("{:.1}", t.bid_price_1))
689                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
690                    ),
691                    8
692                );
693                cell!(
694                    ui.label(
695                        egui::RichText::new(format!("{:.0}", t.bid_volume_1))
696                            .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
697                    ),
698                    9
699                );
700                cell!(
701                    ui.label(
702                        egui::RichText::new(format!("{:.1}", t.ask_price_1))
703                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
704                    ),
705                    10
706                );
707                cell!(
708                    ui.label(
709                        egui::RichText::new(format!("{:.0}", t.ask_volume_1))
710                            .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
711                    ),
712                    11
713                );
714                cell!(
715                    ui.label(if t.update_time.is_empty() {
716                        fmt_dt(Some(t.datetime))
717                    } else {
718                        t.update_time.clone()
719                    }),
720                    12
721                );
722                cell!(ui.label(t.gateway_name.clone()), 13);
723                if dbl {
724                    shared.open_kline(t.symbol.clone());
725                }
726                paint_row_bg(ui, &cells, row_sel);
727                ui.end_row();
728            }
729        },
730    );
731}
732
733// ================= 下单面板(常驻) =================
734
735fn render_order_panel(ui: &mut egui::Ui, shared: &UiShared) {
736    // BD-UI-31(R4):面板题字撤除(下单侧栏即身份)。
737    let mut f = shared.order_form();
738    let before = f.clone();
739    let mut err: Option<String> = None;
740
741    // 随价勾选:价格框随该合约最新 tick(勾选态)
742    if f.follow_price {
743        if let Some(t) = tick_of(shared, &f.exchange, &f.symbol) {
744            if t.last_price > 0.0 {
745                f.price = format!("{:.1}", t.last_price);
746            }
747        }
748    }
749
750    // 交易所下拉:有 tick 的所 ∪ 六家常用所(操作面默认,不是配置面)
751    let mut ex_opts: Vec<String> = shared
752        .tick_groups()
753        .iter()
754        .map(|(e, _)| e.clone())
755        .collect();
756    for e in ["SHFE", "DCE", "CZCE", "CFFEX", "INE", "GFEX"] {
757        if !ex_opts.iter().any(|x| x == e) {
758            ex_opts.push(e.to_string());
759        }
760    }
761    if !ex_opts.contains(&f.exchange) {
762        ex_opts.insert(0, f.exchange.clone());
763    }
764    // 接口下拉:账户清册(注册序)∪ trade_snap 网关去重集(B3 退役:
765    // 不再硬编码 `["ctp"]`;0.2.10 BD-UI-38 sim 纸面网关退场同口径)。
766    let ts = shared.trade_snap();
767    let mut gw_opts: Vec<String> = Vec::new();
768    // 0.2.18(规划 Q4):下单归属只列**已装配**账户 —— 只添加了还没连接的账户
769    // 进了下拉,选它下单只会撞 `unknown:gateway`。
770    // 账本侧历史网关名的口径(执行核查补的洞):清册非空时,**不在清册的历史网关
771    // = 已摘除账户**,它留在下拉里同样是选它下单撞 `unknown:gateway` → 不进下拉
772    // (撤单不受影响:撤单归属按每笔单自己的网关走,与这里的下拉无关)。
773    // 清册为空 = 不干预(纯账本巡回钉 s27/s14 那类无账户配置面的形)。
774    let known_gws = account_gateways(shared);
775    for g in shared
776        .accounts()
777        .iter()
778        .filter(|a| a.connected)
779        .map(|a| a.gateway.clone())
780        .chain(
781            ts.orders
782                .iter()
783                .map(|o| o.gateway_name.clone())
784                .chain(ts.trades.iter().map(|t| t.gateway_name.clone()))
785                .chain(ts.accounts.iter().map(|a| a.gateway_name.clone()))
786                .filter(|g| known_gws.is_empty() || known_gws.contains(g)),
787        )
788    {
789        if !g.is_empty() && !gw_opts.contains(&g) {
790            gw_opts.push(g);
791        }
792    }
793    if !gw_opts.contains(&f.gateway)
794        && !f.gateway.is_empty()
795        && (known_gws.is_empty() || known_gws.contains(&f.gateway))
796    {
797        gw_opts.insert(0, f.gateway.clone());
798    }
799    // 手动选过的网关若已不在可选面(被摘除 / 未装配)= 就地回落到默认阶梯:
800    // 否则下拉里挂着一个注定 `unknown:gateway` 的选项,且它会成为下一笔单的归属。
801    if !f.gateway.is_empty() && !gw_opts.contains(&f.gateway) {
802        f.gateway = shared.default_gateway().unwrap_or_default();
803    }
804    // 表单网关位为空 = 未手动选过:回填默认账户阶梯(上次选择 → 首个已连接 → 首个)。
805    // 只改本地 `f` 副本 —— 面板末尾的 `f != before` 写回负责落盘,不在这里重复写。
806    if f.gateway.is_empty() {
807        if let Some(def) = shared.default_gateway() {
808            f.gateway = def;
809        }
810    }
811    let no_accounts = gw_opts.is_empty();
812
813    // 表单网格(Python TradingWidget 字段排;委托/全撤 = 网格行;
814    // 网格后不挂面板内容 —— 帧内 sizing 退让会把后置行叠回网格,盘口独立帧)。
815    egui::Grid::new("order_form_grid")
816        .num_columns(2)
817        .show(ui, |ui| {
818            ui.label("交易所");
819            egui::ComboBox::from_id_salt("of_exchange")
820                .selected_text(f.exchange.clone())
821                .show_ui(ui, |ui| {
822                    for e in &ex_opts {
823                        ui.selectable_value(&mut f.exchange, e.clone(), e.clone());
824                    }
825                });
826            ui.end_row();
827            ui.label("代码");
828            let sym_resp = ui.add(
829                egui::TextEdit::singleline(&mut f.symbol)
830                    .hint_text("cu2709")
831                    .desired_width(150.0),
832            );
833            // 代码回车 = 订阅(Python TradingWidget returnPressed → set_en_symbol 等价)。
834            // egui 单行 TextEdit 遇 Enter 即 surrender_focus,has_focus 在检查时已
835            // false —— 规范口径 lost_focus + key_pressed(第五轮审计钉:s20 实测
836            // has_focus 口径回车订阅从不触发)。
837            if sym_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
838                let s = f.symbol.trim().to_string();
839                // 与查询合约窗同口径:输入带 EX. 前缀则剥成裸代码(ctp.subscribe
840                // 收裸码);剥后回写表单,名称/盘口/随价的 tick 查找同步可用。
841                let bare = match s.split_once('.') {
842                    Some((_, b)) => b.trim().to_string(),
843                    None => s,
844                };
845                if !bare.is_empty() {
846                    f.symbol = bare.clone();
847                    // 订阅一律路由到行情元账号(规划 §5.C:单源;从账号无 MD 会话,
848                    // 发给它只会静默成功 = 观测黑洞,所以路由在界面侧定死)。
849                    match subscribe_target(shared) {
850                        Ok(gw) => {
851                            shared.push_cmd(UiCmd::CtpSubscribe {
852                                gateway: gw.clone(),
853                                symbols: vec![bare.clone()],
854                            });
855                            shared
856                                .log_line(format!("[ops] 订阅已入列 {bare} → {gw}(代码回车订阅)"));
857                        }
858                        Err(e) => shared.log_line(format!("[订阅] {e}")),
859                    }
860                }
861            }
862            ui.end_row();
863            ui.label("名称");
864            ui.label(
865                tick_of(shared, &f.exchange, &f.symbol)
866                    .map(|t| {
867                        if t.name.is_empty() {
868                            "-".to_string()
869                        } else {
870                            t.name
871                        }
872                    })
873                    .unwrap_or_else(|| "-".to_string()),
874            );
875            ui.end_row();
876            ui.label("方向");
877            egui::ComboBox::from_id_salt("of_direction")
878                .selected_text(f.direction.as_str())
879                .show_ui(ui, |ui| {
880                    for d in [Direction::LONG, Direction::SHORT] {
881                        ui.selectable_value(&mut f.direction, d, d.as_str());
882                    }
883                });
884            ui.end_row();
885            ui.label("开平");
886            ui.horizontal(|ui| {
887                egui::ComboBox::from_id_salt("of_offset")
888                    .selected_text(f.offset.as_str())
889                    .show_ui(ui, |ui| {
890                        for o in [
891                            Offset::OPEN,
892                            Offset::CLOSE,
893                            Offset::CLOSETODAY,
894                            Offset::CLOSEYESTERDAY,
895                        ] {
896                            ui.selectable_value(&mut f.offset, o, o.as_str());
897                        }
898                    });
899                // 0.1.1 BD-UI-30:自动开平勾选(开 = Auto 走拆单器;关 = None 原样)。
900                ui.checkbox(&mut f.auto_split, "自动开平");
901            });
902            ui.end_row();
903            ui.label("类型");
904            egui::ComboBox::from_id_salt("of_order_type")
905                .selected_text(f.order_type.as_str())
906                .show_ui(ui, |ui| {
907                    for ot in [OrderType::LIMIT, OrderType::MARKET] {
908                        ui.selectable_value(&mut f.order_type, ot, ot.as_str());
909                    }
910                });
911            ui.end_row();
912            ui.label("价格");
913            ui.horizontal(|ui| {
914                ui.add(
915                    egui::TextEdit::singleline(&mut f.price)
916                        .hint_text("107660")
917                        .desired_width(90.0),
918                );
919                ui.checkbox(&mut f.follow_price, "随价");
920            });
921            ui.end_row();
922            ui.label("数量");
923            ui.add(
924                egui::TextEdit::singleline(&mut f.volume)
925                    .hint_text("1")
926                    .desired_width(90.0),
927            );
928            ui.end_row();
929            ui.label("接口");
930            egui::ComboBox::from_id_salt("of_gateway")
931                .selected_text(if f.gateway.is_empty() {
932                    "(未选账户)".to_string()
933                } else {
934                    f.gateway.clone()
935                })
936                .show_ui(ui, |ui| {
937                    for g in &gw_opts {
938                        ui.selectable_value(&mut f.gateway, g.clone(), g.clone());
939                    }
940                });
941            ui.end_row();
942            ui.label("操作");
943            ui.horizontal(|ui| {
944                // 无账户 = 无网关可下单(旧「空 → 字面量 ctp」兜底已退役,B4)。
945                let send = ui
946                    .add_enabled_ui(!no_accounts, |ui| ui.button(BTN_SEND_ORDER))
947                    .inner
948                    .clicked();
949                if send {
950                    match f.to_request() {
951                        Ok(req) => {
952                            let fallback = shared.default_gateway();
953                            match crate::state::parse_gateway(&f.gateway, fallback.as_deref()) {
954                                Ok(gateway) => {
955                                    shared.push_cmd(UiCmd::SendOrder {
956                                        req,
957                                        gateway,
958                                        mode: if f.auto_split {
959                                            ek_oms::LockMode::Auto
960                                        } else {
961                                            ek_oms::LockMode::None
962                                        },
963                                    });
964                                    shared.log_line("[ops] 下单请求已入列(等 eTimer 请辞)");
965                                }
966                                Err(e) => {
967                                    shared.log_line(format!("[下单] {e}"));
968                                    err = Some(e);
969                                }
970                            }
971                        }
972                        Err(e) => {
973                            shared.log_line(format!("[下单] {e}"));
974                            err = Some(e);
975                        }
976                    }
977                }
978                if ui.button(BTN_CANCEL_ALL).clicked() {
979                    // 全撤 = 活动委托逐个走 oms.cancel_order(界面侧扇出;
980                    // 守卫逐单计数在 oms,不可绕过——0.1.1 BD-UI-30)
981                    let mut n = 0usize;
982                    for o in ts.orders.iter().filter(|o| order_active(o.status)) {
983                        // 撤单跟着**这笔单自己的网关**走(多账号面:不再回落 ctp)。
984                        let gw = if !o.gateway_name.is_empty() {
985                            o.gateway_name.clone()
986                        } else {
987                            shared.default_gateway().unwrap_or_default()
988                        };
989                        if gw.is_empty() {
990                            continue;
991                        }
992                        shared.push_cmd(UiCmd::CancelOrder {
993                            orderid: o.orderid.clone(),
994                            gateway: gw,
995                        });
996                        n += 1;
997                    }
998                    shared.log_line(format!("[ops] 全撤入列 {n} 笔(等 eTimer 请辞)"));
999                }
1000            });
1001            ui.end_row();
1002            ui.label("代码回车 = 订阅");
1003            ui.label("");
1004            ui.end_row();
1005        });
1006    if let Some(e) = err {
1007        ui.label(egui::RichText::new(e).color(egui::Color32::RED));
1008    }
1009    if no_accounts {
1010        // 侧栏底部独立行(不落钮行内:会把「全撤」推出侧栏宽域,s3 结构钉)。
1011        ui.label(
1012            egui::RichText::new("无账户:先在「账户」面板添加账户(委托钮已禁用)")
1013                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1014        );
1015    }
1016    if f != before {
1017        shared.set_order_form(f.clone());
1018    }
1019}
1020
1021/// 盘口帧(选中合约 tick 五档;Python 买5卖5 等价;独立帧避网格叠行)。
1022/// BD-UI-31(R4):题字撤除(含括号合约代码——选中合约已由下单面板代码格与
1023/// 行情带跳色行指认,盘口帧不重复报幕)。
1024fn render_book_frame(ui: &mut egui::Ui, shared: &UiShared) {
1025    let f = shared.order_form();
1026    match tick_of(shared, &f.exchange, &f.symbol) {
1027        Some(t) => {
1028            for i in (1..=5).rev() {
1029                let (p, v) = match i {
1030                    1 => (t.ask_price_1, t.ask_volume_1),
1031                    2 => (t.ask_price_2, t.ask_volume_2),
1032                    3 => (t.ask_price_3, t.ask_volume_3),
1033                    4 => (t.ask_price_4, t.ask_volume_4),
1034                    _ => (t.ask_price_5, t.ask_volume_5),
1035                };
1036                ui.label(
1037                    egui::RichText::new(format!("卖{i}  {:.1}  {:.0}", p, v))
1038                        .color(egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)),
1039                );
1040            }
1041            for i in 1..=5 {
1042                let (p, v) = match i {
1043                    1 => (t.bid_price_1, t.bid_volume_1),
1044                    2 => (t.bid_price_2, t.bid_volume_2),
1045                    3 => (t.bid_price_3, t.bid_volume_3),
1046                    4 => (t.bid_price_4, t.bid_volume_4),
1047                    _ => (t.bid_price_5, t.bid_volume_5),
1048                };
1049                ui.label(
1050                    egui::RichText::new(format!("买{i}  {:.1}  {:.0}", p, v))
1051                        .color(egui::Color32::from_rgb(0xE0, 0x50, 0x50)),
1052                );
1053            }
1054        }
1055        None => {
1056            ui.label("(无 tick)");
1057        }
1058    }
1059}
1060
1061// ================= 2×2 监控格 =================
1062
1063fn render_topleft_group(ui: &mut egui::Ui, shared: &UiShared) {
1064    let tab = shared.topleft_tab();
1065    ui.horizontal(|ui| {
1066        if ui
1067            .selectable_label(tab == TopLeftTab::Orders, TopLeftTab::Orders.label())
1068            .clicked()
1069        {
1070            shared.set_topleft_tab(TopLeftTab::Orders);
1071        }
1072        if ui
1073            .selectable_label(tab == TopLeftTab::Active, TopLeftTab::Active.label())
1074            .clicked()
1075        {
1076            shared.set_topleft_tab(TopLeftTab::Active);
1077        }
1078    });
1079    render_orders_table(ui, shared, tab == TopLeftTab::Active);
1080}
1081
1082/// 委托表(17 列逐值 = Python OrderMonitor 蓝图:代码/名称/交易所/类型/方向/
1083/// 开平/价格/总量/已成/成交均价/状态/状态描述/时间/委托号/来源/接口 + 操作;
1084/// 活动行撤单钮 + 双击撤单,终态行只读)。
1085fn render_orders_table(ui: &mut egui::Ui, shared: &UiShared, active_only: bool) {
1086    let ts = shared.trade_snap();
1087    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1088    let known_gws = account_gateways(shared);
1089    // 表题撤除(第五轮审计):tab「委托|未成交」即 Python 口径的表身份,
1090    // 再挂同名 label = 三重撞名(下单钮/tab/表题),纯冗余。
1091    let rows: Vec<_> = ts
1092        .orders
1093        .iter()
1094        .filter(|o| !active_only || order_active(o.status))
1095        .collect();
1096    // 委托表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1097    const ORDER_HEADERS: [&str; 17] = [
1098        "代码",
1099        "名称",
1100        "交易所",
1101        "类型",
1102        "方向",
1103        "开平",
1104        "价格",
1105        "总量",
1106        "已成",
1107        "成交均价",
1108        "状态",
1109        "状态描述",
1110        "时间",
1111        "委托号",
1112        "来源",
1113        "接口",
1114        "操作",
1115    ];
1116    if rows.is_empty() {
1117        // 空态提示移入表格 body(BD-UI-35,用户裁定:提示不得在表头上方,
1118        // 应在表头之下):label 成为 grid 首格 = 表头下第一行,格不缩。
1119        pinned_table(
1120            ui,
1121            if active_only {
1122                "active_grid"
1123            } else {
1124                "order_grid"
1125            },
1126            17,
1127            &ORDER_HEADERS,
1128            |ui| {
1129                ui.label(if active_only {
1130                    "(无未成交)"
1131                } else {
1132                    "(无委托:等网关 send_order)"
1133                });
1134            },
1135        );
1136        return;
1137    }
1138    pinned_table(
1139        ui,
1140        if active_only {
1141            "active_grid"
1142        } else {
1143            "order_grid"
1144        },
1145        17,
1146        &ORDER_HEADERS,
1147        |ui| {
1148            for o in &rows {
1149                // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1150                // 任何一格双击 = 撤单(Python OrderMonitor itemDoubleClicked→
1151                // cancel_order 等价,活动行限定;显式撤单钮同令并存)。
1152                let key = format!("{}.{}", o.gateway_name, o.orderid);
1153                let row_sel = shared.row_selected("orders", &key);
1154                let mut cells = Vec::with_capacity(17);
1155                let mut dbl = false;
1156                let sel = ui.selectable_label(row_sel, o.symbol.clone());
1157                if sel.clicked() {
1158                    shared.select_row("orders", &key);
1159                }
1160                dbl |= sel.double_clicked();
1161                cells.push(sel.rect);
1162                macro_rules! cell {
1163                    ($base:expr, $col:expr) => {{
1164                        let base = $base;
1165                        let r = click_cell(ui, base.rect, ("ord", &key, $col));
1166                        if r.clicked() {
1167                            shared.select_row("orders", &key);
1168                        }
1169                        dbl |= r.double_clicked();
1170                        cells.push(base.rect);
1171                    }};
1172                }
1173                cell!(
1174                    ui.label(if o.name.is_empty() {
1175                        "-".to_string()
1176                    } else {
1177                        o.name.clone()
1178                    }),
1179                    1
1180                );
1181                cell!(ui.label(o.exchange.as_str().to_string()), 2);
1182                cell!(ui.label(o.order_type.as_str().to_string()), 3);
1183                cell!(
1184                    ui.label(
1185                        egui::RichText::new(o.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1186                            if o.direction == Some(Direction::SHORT) {
1187                                egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1188                            } else {
1189                                egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1190                            },
1191                        ),
1192                    ),
1193                    4
1194                );
1195                cell!(ui.label(o.offset.as_str().to_string()), 5);
1196                cell!(ui.label(format!("{:.1}", o.price)), 6);
1197                cell!(ui.label(format!("{:.0}", o.volume)), 7);
1198                cell!(ui.label(format!("{:.0}", o.traded)), 8);
1199                cell!(ui.label(format!("{:.1}", o.traded_price)), 9);
1200                cell!(
1201                    ui.label(egui::RichText::new(o.status.as_str()).color(
1202                        if o.status == ek_data::constant::Status::REJECTED {
1203                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1204                        } else if o.status == ek_data::constant::Status::ALLTRADED {
1205                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1206                        } else {
1207                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
1208                        },
1209                    )),
1210                    10
1211                );
1212                cell!(ui.label(o.status_msg.clone()), 11);
1213                cell!(ui.label(fmt_dt(o.datetime)), 12);
1214                cell!(ui.label(o.orderid.clone()), 13);
1215                cell!(
1216                    ui.label(if o.reference.is_empty() {
1217                        "-".to_string()
1218                    } else {
1219                        o.reference.clone()
1220                    }),
1221                    14
1222                );
1223                cell!(ui.label(ledger_gw_label(&o.gateway_name, &known_gws)), 15);
1224                if dbl && order_active(o.status) {
1225                    // 双击撤单同按钮口径:跟着这笔单自己的网关(空串回落默认账户阶梯)。
1226                    let gw = if !o.gateway_name.is_empty() {
1227                        o.gateway_name.clone()
1228                    } else {
1229                        shared.default_gateway().unwrap_or_default()
1230                    };
1231                    if !gw.is_empty() {
1232                        shared.push_cmd(UiCmd::CancelOrder {
1233                            orderid: o.orderid.clone(),
1234                            gateway: gw,
1235                        });
1236                    }
1237                }
1238                let hr = ui.horizontal(|ui| {
1239                    if order_active(o.status)
1240                        && ui
1241                            .small_button(format!("{BTN_CANCEL_ORDER} {}", o.orderid))
1242                            .clicked()
1243                    {
1244                        // 撤单跟随委托的来路网关(行级 gateway_name;
1245                        // 多账号面:空串回落默认账户阶梯,不再硬编码 ctp)
1246                        let gw = if !o.gateway_name.is_empty() {
1247                            o.gateway_name.clone()
1248                        } else {
1249                            shared.default_gateway().unwrap_or_default()
1250                        };
1251                        if gw.is_empty() {
1252                            shared.log_line(format!("[撤单] {} 无网关可撤(账户清册空)", o.orderid));
1253                        } else {
1254                            shared.push_cmd(UiCmd::CancelOrder {
1255                                orderid: o.orderid.clone(),
1256                                gateway: gw,
1257                            });
1258                        }
1259                    }
1260                });
1261                cells.push(hr.response.rect);
1262                paint_row_bg(ui, &cells, row_sel);
1263                ui.end_row();
1264            }
1265        },
1266    );
1267}
1268
1269/// 成交组(12 列逐值 = Python TradeMonitor 蓝图)。
1270fn render_trades_group(ui: &mut egui::Ui, shared: &UiShared) {
1271    ui.label("成交");
1272    let ts = shared.trade_snap();
1273    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1274    let known_gws = account_gateways(shared);
1275    // 成交表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1276    const TRADE_HEADERS: [&str; 12] = [
1277        "成交号",
1278        "代码",
1279        "名称",
1280        "交易所",
1281        "方向",
1282        "开平",
1283        "价格",
1284        "数量",
1285        "成交额",
1286        "时间",
1287        "委托号",
1288        "接口",
1289    ];
1290    if ts.trades.is_empty() {
1291        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1292        pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1293            ui.label("(无成交)");
1294        });
1295        return;
1296    }
1297    pinned_table(ui, "trade_grid", 12, &TRADE_HEADERS, |ui| {
1298        for t in &ts.trades {
1299            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1300            // 方向列上色(R2,与委托/持仓表同口径:空绿/多红)。
1301            let key = format!("{}.{}", t.gateway_name, t.tradeid);
1302            let row_sel = shared.row_selected("trades", &key);
1303            let mut cells = Vec::with_capacity(12);
1304            let sel = ui.selectable_label(row_sel, t.tradeid.clone());
1305            if sel.clicked() {
1306                shared.select_row("trades", &key);
1307            }
1308            cells.push(sel.rect);
1309            macro_rules! cell {
1310                ($base:expr, $col:expr) => {{
1311                    let base = $base;
1312                    let r = click_cell(ui, base.rect, ("trd", &key, $col));
1313                    if r.clicked() {
1314                        shared.select_row("trades", &key);
1315                    }
1316                    cells.push(base.rect);
1317                }};
1318            }
1319            cell!(ui.label(t.symbol.clone()), 1);
1320            cell!(
1321                ui.label(if t.name.is_empty() {
1322                    "-".to_string()
1323                } else {
1324                    t.name.clone()
1325                }),
1326                2
1327            );
1328            cell!(ui.label(t.exchange.as_str().to_string()), 3);
1329            cell!(
1330                ui.label(
1331                    egui::RichText::new(t.direction.map(|d| d.as_str()).unwrap_or("-")).color(
1332                        if t.direction == Some(Direction::SHORT) {
1333                            egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1334                        } else {
1335                            egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1336                        },
1337                    ),
1338                ),
1339                4
1340            );
1341            cell!(ui.label(t.offset.as_str().to_string()), 5);
1342            cell!(ui.label(format!("{:.1}", t.price)), 6);
1343            cell!(ui.label(format!("{:.0}", t.volume)), 7);
1344            cell!(ui.label(format!("{:.1}", t.traded_amount)), 8);
1345            cell!(ui.label(fmt_dt(t.datetime)), 9);
1346            cell!(ui.label(t.orderid.clone()), 10);
1347            cell!(ui.label(ledger_gw_label(&t.gateway_name, &known_gws)), 11);
1348            paint_row_bg(ui, &cells, row_sel);
1349            ui.end_row();
1350        }
1351    });
1352}
1353
1354/// 持仓组(16 列逐值 = Python PositionMonitor 蓝图;双击行带入下单面板,
1355/// Python mainwindow.py:276 等价,不开 K线)。
1356fn render_positions_group(ui: &mut egui::Ui, shared: &UiShared) {
1357    ui.label("持仓");
1358    let ts = shared.trade_snap();
1359    // 摘除标注口径(规划 U8):清册非空时,账本行网关不在清册 = 该账户已摘除。
1360    let known_gws = account_gateways(shared);
1361    // 持仓表列头单源(空/非空两分支同源,0.2.7 审计口径)。
1362    const POS_HEADERS: [&str; 16] = [
1363        "代码",
1364        "名称",
1365        "交易所",
1366        "方向",
1367        "数量",
1368        "昨仓",
1369        "可用",
1370        "冻结",
1371        "在途",
1372        "市值",
1373        "成本",
1374        "均价",
1375        "最新价",
1376        "盈亏",
1377        "盈亏比",
1378        "接口",
1379    ];
1380    if ts.positions.is_empty() {
1381        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径)
1382        pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1383            ui.label("(无持仓)");
1384        });
1385        return;
1386    }
1387    pinned_table(ui, "pos_grid", 16, &POS_HEADERS, |ui| {
1388        for p in &ts.positions {
1389            // 行交互(BD-UI-31 扩展):行内任何一格单击 = 选中行(跳色);
1390            // 任何一格双击 = 带入下单面板(Python mainwindow.py:276 等价,
1391            // 不开 K线;方向随持仓方向,Python 同口径)。
1392            let key = format!("{}.{}.{}", p.gateway_name, p.symbol, p.direction.as_str());
1393            let row_sel = shared.row_selected("positions", &key);
1394            let mut cells = Vec::with_capacity(16);
1395            let mut dbl = false;
1396            let sel = ui.selectable_label(row_sel, p.symbol.clone());
1397            if sel.clicked() {
1398                shared.select_row("positions", &key);
1399            }
1400            dbl |= sel.double_clicked();
1401            cells.push(sel.rect);
1402            macro_rules! cell {
1403                ($base:expr, $col:expr) => {{
1404                    let base = $base;
1405                    let r = click_cell(ui, base.rect, ("pos", &key, $col));
1406                    if r.clicked() {
1407                        shared.select_row("positions", &key);
1408                    }
1409                    dbl |= r.double_clicked();
1410                    cells.push(base.rect);
1411                }};
1412            }
1413            cell!(
1414                ui.label(if p.name.is_empty() {
1415                    "-".to_string()
1416                } else {
1417                    p.name.clone()
1418                }),
1419                1
1420            );
1421            cell!(ui.label(p.exchange.as_str().to_string()), 2);
1422            cell!(
1423                ui.label(egui::RichText::new(p.direction.as_str()).color(
1424                    if p.direction == ek_data::constant::Direction::SHORT {
1425                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1426                    } else {
1427                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1428                    },
1429                )),
1430                3
1431            );
1432            cell!(ui.label(format!("{:.0}", p.volume)), 4);
1433            cell!(ui.label(format!("{:.0}", p.yd_volume)), 5);
1434            cell!(ui.label(format!("{:.0}", p.can_use_volume)), 6);
1435            cell!(ui.label(format!("{:.0}", p.frozen)), 7);
1436            cell!(ui.label(format!("{:.0}", p.on_road_volume)), 8);
1437            cell!(ui.label(format!("{:.1}", p.market_value)), 9);
1438            cell!(ui.label(format!("{:.2}", p.price)), 10);
1439            cell!(ui.label(format!("{:.2}", p.avg_price)), 11);
1440            cell!(ui.label(format!("{:.2}", p.last_price)), 12);
1441            cell!(
1442                ui.label(
1443                    egui::RichText::new(format!("{:+.1}", p.pnl)).color(if p.pnl < 0.0 {
1444                        egui::Color32::from_rgb(0x2E, 0x9E, 0x2E)
1445                    } else {
1446                        egui::Color32::from_rgb(0xE0, 0x50, 0x50)
1447                    }),
1448                ),
1449                13
1450            );
1451            cell!(ui.label(format!("{:+.4}", p.profit_rate)), 14);
1452            cell!(ui.label(ledger_gw_label(&p.gateway_name, &known_gws)), 15);
1453            if dbl {
1454                let mut f = shared.order_form();
1455                f.symbol = p.symbol.clone();
1456                f.exchange = p.exchange.as_str().to_string();
1457                f.direction = p.direction;
1458                // B1 修:行级网关带入(旧实现不带网关 → 平掉哪条腿由上一单残留决定)。
1459                if !p.gateway_name.is_empty() {
1460                    f.gateway = p.gateway_name.clone();
1461                }
1462                shared.set_order_form(f);
1463                shared.log_line(format!(
1464                    "[ops] 持仓带入下单面板 {}(接口 {})",
1465                    p.symbol, p.gateway_name
1466                ));
1467            }
1468            paint_row_bg(ui, &cells, row_sel);
1469            ui.end_row();
1470        }
1471    });
1472}
1473
1474fn render_bottomright_group(ui: &mut egui::Ui, shared: &UiShared) {
1475    let tab = shared.bottomright_tab();
1476    ui.horizontal(|ui| {
1477        if ui
1478            .selectable_label(
1479                tab == BottomRightTab::Account,
1480                BottomRightTab::Account.label(),
1481            )
1482            .clicked()
1483        {
1484            shared.set_bottomright_tab(BottomRightTab::Account);
1485        }
1486        if ui
1487            .selectable_label(tab == BottomRightTab::Logs, BottomRightTab::Logs.label())
1488            .clicked()
1489        {
1490            shared.set_bottomright_tab(BottomRightTab::Logs);
1491        }
1492    });
1493    match tab {
1494        BottomRightTab::Account => {
1495            let ts = shared.trade_snap();
1496            if ts.accounts.is_empty() {
1497                ui.label("(无资金:等网关 eAccount.)");
1498                return;
1499            }
1500            egui::Grid::new("acct_grid")
1501                .striped(true)
1502                .num_columns(5)
1503                .show(ui, |ui| {
1504                    for h in ["账号", "接口", "余额", "可用", "冻结"] {
1505                        ui.strong(h);
1506                    }
1507                    ui.end_row();
1508                    for a in &ts.accounts {
1509                        ui.label(a.accountid.clone());
1510                        ui.label(a.gateway_name.clone());
1511                        ui.label(format!("{:.2}", a.balance));
1512                        ui.label(format!("{:.2}", a.available()));
1513                        ui.label(format!("{:.2}", a.frozen));
1514                        ui.end_row();
1515                    }
1516                });
1517        }
1518        BottomRightTab::Logs => {
1519            render_logs(ui, shared);
1520        }
1521    }
1522}
1523
1524fn render_logs(ui: &mut egui::Ui, shared: &UiShared) {
1525    ui.horizontal(|ui| {
1526        if ui.button(BTN_CLEAR_LOGS).clicked() {
1527            shared.clear_logs(); // LOCAL-FIRST:就地清,零往返
1528        }
1529    });
1530    let (_, spilled) = shared.logs_snapshot();
1531    if spilled > 0 {
1532        // 口径写明(G1 实连证据的收口):回扫日志取证不可靠 —— 巡回里「重加后 90 秒
1533        // 没抓到『交易服务器登录成功』行」不是没登录,是这一环被状态推送流挤满了。
1534        // 登录/掉线/查询被拒这些事实的事实源是账户面板的状态列与状态细节(网关状态口),
1535        // 不是日志窗。单独一行,不挤在钮旁:长文案在横向行里会被压(s39 那类实锤)。
1536        ui.label(format!(
1537            "已丢弃 {spilled} 条(环形 {}):日志窗是窗口不是历史 —— 登录/掉线/查询被拒看账户面板的状态列与状态细节,别回扫这里",
1538            crate::state::RING_CAP
1539        ));
1540    }
1541    let lines = shared.logs_snapshot().0;
1542    egui::ScrollArea::vertical()
1543        .id_salt("log_scroll")
1544        .max_height(180.0)
1545        .stick_to_bottom(true)
1546        .show(ui, |ui| {
1547            for line in lines {
1548                ui.monospace(line);
1549            }
1550        });
1551}
1552
1553// ================= 独立窗面(BD-UI-32/33)=================
1554
1555/// 独立窗几何(BD-UI-34:助手参数收敛,8→6 闸 clippy too_many_arguments)。
1556struct WinGeom {
1557    inner: [f32; 2],
1558    min_inner: [f32; 2],
1559    maximized: bool,
1560}
1561
1562/// 独立 OS 窗统一开法(BD-UI-33,四窗一型:运维/CTP/查询合约/K线)。
1563///
1564/// - **守卫(0.2.3 教训内建):** 调用方按状态位辞行——位关不调本函数 =
1565///   viewport 本拍未 used = egui/eframe GC 销毁 OS 窗(销毁判据源码
1566///   context.rs「never used this pass」)。
1567/// - Embedded 回退(后端不支持多窗:kittest/WEB)= 主窗内 egui::Window 同本体,
1568///   回退面关闭 = embedded 闭包返回 true。
1569/// - Immediate 面 OS X = close_requested → 返回 true(调用方翻负状态位)。
1570/// - `geom.maximized` = 打开即最大化(BD-UI-34,K线窗用户裁定;OS 窗属性,
1571///   Embedded 回退面无此概念 = 忽略)。
1572/// - `body` 返回 true = 内容侧请求关闭(BD-UI-34:K线窗 ESC 键快捷关闭;
1573///   键事件按子窗焦点路由,主窗按键不会误关)。
1574///
1575/// 返回 = "本拍该窗被关"(调用方负责状态位/列表出表)。
1576fn show_independent_window(
1577    ctx: &egui::Context,
1578    id_salt: impl std::hash::Hash,
1579    title: &str,
1580    geom: &WinGeom,
1581    embedded: impl Fn(&egui::Context) -> bool,
1582    body: impl Fn(&mut egui::Ui) -> bool,
1583) -> bool {
1584    let closed = std::cell::Cell::new(false);
1585    let mut builder = egui::ViewportBuilder::default()
1586        .with_title(title)
1587        .with_inner_size(geom.inner)
1588        .with_min_inner_size(geom.min_inner)
1589        .with_minimize_button(true)
1590        .with_maximize_button(true)
1591        .with_close_button(true);
1592    if geom.maximized {
1593        builder = builder.with_maximized(true);
1594    }
1595    ctx.show_viewport_immediate(
1596        egui::ViewportId::from_hash_of(id_salt),
1597        builder,
1598        |ctx2, class| {
1599            if class == egui::ViewportClass::Embedded {
1600                closed.set(embedded(ctx2));
1601                return;
1602            }
1603            if ctx2.input(|i| i.viewport().close_requested()) {
1604                closed.set(true);
1605                return;
1606            }
1607            egui::CentralPanel::default().show(ctx2, |ui| {
1608                if body(ui) {
1609                    closed.set(true);
1610                }
1611            });
1612        },
1613    );
1614    closed.get()
1615}
1616
1617fn render_windows(ctx: &egui::Context, shared: &UiShared) {
1618    // 运维窗(菜单「运维」;BD-UI-33 转独立 OS 窗,用户裁定「四窗皆可脱离主窗」)。
1619    if shared.ops_window_open() {
1620        let closed = show_independent_window(
1621            ctx,
1622            "ops_win",
1623            "运维窗",
1624            &WinGeom {
1625                inner: [680.0, 420.0],
1626                min_inner: [420.0, 260.0],
1627                maximized: false,
1628            },
1629            |ctx2| {
1630                let mut open = shared.ops_window_open();
1631                egui::Window::new("运维窗")
1632                    .id(egui::Id::new("ops_win"))
1633                    .default_pos([80.0, 320.0])
1634                    .default_size([640.0, 380.0])
1635                    .open(&mut open)
1636                    .show(ctx2, |ui| {
1637                        render_ops_window(ui, shared);
1638                    });
1639                shared.set_ops_window_open(open);
1640                !open
1641            },
1642            |ui| {
1643                render_ops_window(ui, shared);
1644                false
1645            },
1646        );
1647        if closed {
1648            shared.set_ops_window_open(false);
1649        }
1650    }
1651
1652    // 账户面板(菜单「账户」;多账号面:账户清单 + 行情源选择 + 添加/编辑表单;
1653    // 旧「CTP 网关配置窗」退役收纳于此窗,BD-UI-33 独立 OS 窗形态沿用)。
1654    if shared.accounts_window_open() {
1655        let closed = show_independent_window(
1656            ctx,
1657            "account_win",
1658            "账户面板",
1659            &WinGeom {
1660                inner: [760.0, 520.0],
1661                min_inner: [520.0, 320.0],
1662                maximized: false,
1663            },
1664            |ctx2| {
1665                let mut open = shared.accounts_window_open();
1666                egui::Window::new("账户面板")
1667                    .id(egui::Id::new("account_win"))
1668                    .default_pos([640.0, 320.0])
1669                    .default_size([720.0, 470.0])
1670                    .open(&mut open)
1671                    .show(ctx2, |ui| {
1672                        render_accounts_window(ui, shared);
1673                    });
1674                shared.set_accounts_window_open(open);
1675                !open
1676            },
1677            |ui| {
1678                render_accounts_window(ui, shared);
1679                false
1680            },
1681        );
1682        if closed {
1683            shared.set_accounts_window_open(false);
1684        }
1685    }
1686
1687    // 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
1688    // 『添加账户』与『编辑 <账户>』开同一个弹窗的两种模式;**非 modal**(不阻塞主窗与行情观察)。
1689    // 关闭(窗 × 或『取消』)= 放弃未保存(规划 Q24),绝不静默写入清册留档。
1690    if shared.account_edit_open() {
1691        let closed = show_independent_window(
1692            ctx,
1693            "account_edit_win",
1694            "账户配置",
1695            &WinGeom {
1696                inner: [680.0, 340.0],
1697                min_inner: [480.0, 240.0],
1698                maximized: false,
1699            },
1700            |ctx2| {
1701                let mut open = shared.account_edit_open();
1702                let title = match shared.editing_account() {
1703                    Some(gw) => format!("账户配置 — 编辑 {gw}"),
1704                    None => "账户配置 — 添加".to_string(),
1705                };
1706                egui::Window::new(title)
1707                    .id(egui::Id::new("account_edit_win"))
1708                    .default_pos([700.0, 380.0])
1709                    .default_size([660.0, 320.0])
1710                    .open(&mut open)
1711                    .show(ctx2, |ui| {
1712                        render_account_edit_window(ui, shared);
1713                    });
1714                if !open {
1715                    shared.end_account_edit();
1716                }
1717                !open
1718            },
1719            |ui| {
1720                render_account_edit_window(ui, shared);
1721                false
1722            },
1723        );
1724        if closed && shared.account_edit_open() {
1725            shared.end_account_edit();
1726        }
1727    }
1728
1729    // 查询合约窗(菜单「查询合约」;Python ContractManager 等价:查询 + 勾选 + 订阅)。
1730    if shared.contracts_window_open() {
1731        let closed = show_independent_window(
1732            ctx,
1733            "contracts_win",
1734            "查询合约窗",
1735            &WinGeom {
1736                inner: [620.0, 460.0],
1737                min_inner: [420.0, 260.0],
1738                maximized: false,
1739            },
1740            |ctx2| {
1741                let mut open = shared.contracts_window_open();
1742                egui::Window::new("查询合约窗")
1743                    .id(egui::Id::new("contracts_win"))
1744                    .default_pos([420.0, 300.0])
1745                    .default_size([560.0, 420.0])
1746                    .open(&mut open)
1747                    .show(ctx2, |ui| {
1748                        render_contracts_window(ui, shared);
1749                    });
1750                shared.set_contracts_window_open(open);
1751                !open
1752            },
1753            |ui| {
1754                render_contracts_window(ui, shared);
1755                false
1756            },
1757        );
1758        if closed {
1759            shared.set_contracts_window_open(false);
1760        }
1761    }
1762
1763    // K线窗(双击弹出,按合约多开;出 kline_symbols 列表 = 不再 show = 销毁)。
1764    // BD-UI-34:打开即最大化(maximized)+ ESC 键快捷关闭(body 请求关闭;
1765    // 键按子窗焦点路由,主窗按键不误关)。
1766    let mut closed: Vec<String> = Vec::new();
1767    for sym in shared.kline_symbols() {
1768        let is_closed = show_independent_window(
1769            ctx,
1770            format!("kline_win:{sym}"),
1771            &format!("K线 — {sym}"),
1772            &WinGeom {
1773                inner: [560.0, 360.0],
1774                min_inner: [360.0, 220.0],
1775                maximized: true,
1776            },
1777            |ctx2| {
1778                let esc = ctx2.input(|i| i.key_pressed(egui::Key::Escape));
1779                let resp = egui::Window::new(format!("K线 — {sym}"))
1780                    .id(egui::Id::new(("kline_win", sym.clone())))
1781                    .default_pos([300.0, 340.0])
1782                    .default_size([540.0, 330.0])
1783                    .show(ctx2, |ui| {
1784                        let cell = shared
1785                            .market()
1786                            .cells()
1787                            .into_iter()
1788                            .find(|c| c.symbol == sym);
1789                        render_kline_cell(ui, cell.as_ref());
1790                    });
1791                esc || resp.is_none()
1792            },
1793            |ui| {
1794                if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
1795                    return true;
1796                }
1797                let cell = shared
1798                    .market()
1799                    .cells()
1800                    .into_iter()
1801                    .find(|c| c.symbol == sym);
1802                render_kline_cell(ui, cell.as_ref());
1803                false
1804            },
1805        );
1806        if is_closed {
1807            closed.push(sym);
1808        }
1809    }
1810    for s in closed {
1811        shared.close_kline(&s);
1812    }
1813}
1814
1815/// 运维窗:插件表(svctl 操作 + 二次确认)+ 待确认横幅 + 服务目录(三列)
1816/// + 依赖图。回灌界面面已退役(总线口保留;流控查询钮 0.2.10 随 sim 退场撤除)。
1817fn render_ops_window(ui: &mut egui::Ui, shared: &UiShared) {
1818    ui.label("插件");
1819    let snap = shared.snapshot();
1820    // 账户清册里的实例名(一次取好,别在每行每帧各 clone 一遍整表)。
1821    let account_gateways: std::collections::HashSet<String> = shared
1822        .accounts()
1823        .iter()
1824        .map(|a| a.gateway.clone())
1825        .collect();
1826    egui::Grid::new("plug_grid")
1827        .striped(true)
1828        .num_columns(6)
1829        .show(ui, |ui| {
1830            ui.strong("名称");
1831            ui.strong("状态");
1832            ui.strong("版本");
1833            ui.strong("依赖");
1834            ui.strong("统计");
1835            ui.strong("操作");
1836            ui.end_row();
1837            if snap.rows.is_empty() {
1838                ui.label("(快照空:等 eTimer 首拉)");
1839                ui.end_row();
1840            }
1841            for r in &snap.rows {
1842                if is_quarantined(&r.state) {
1843                    ui.label(egui::RichText::new(r.name.clone()).color(egui::Color32::RED));
1844                    ui.label(
1845                        egui::RichText::new(state_text(r))
1846                            .color(egui::Color32::RED)
1847                            .strong(),
1848                    );
1849                } else {
1850                    ui.label(r.name.clone());
1851                    ui.label(state_text(r));
1852                }
1853                ui.label(r.version.clone());
1854                ui.label(format!("deps:[{}]", r.deps.join(",")));
1855                ui.label(format!("spilled:{} panics:{}", r.spilled, r.panics));
1856                ui.horizontal(|ui| {
1857                    // Q17 收口(0.2.20):账户清册里的实例不在运维窗起停。
1858                    // 账户面板的『移除』带全套守卫(活跃委托闸 + 二次确认 + 持仓提示),
1859                    // 运维窗这条是同一动作的无守卫副本 —— 同一个实例两条移除路、守卫只长在
1860                    // 一条上,等于守卫可以绕过(规划 Q17 的边界就在这)。
1861                    // 清册外的 `ctp_*` 实例(手工起的 / 旧流程残留)仍留这条出口,否则没有退路。
1862                    if account_gateways.contains(&r.name) {
1863                        ui.label(
1864                            egui::RichText::new("起停归『账户』面板(带撤单守卫)")
1865                                .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
1866                        );
1867                    } else {
1868                        if ui.small_button(format!("重注入 {}", r.name)).clicked() {
1869                            // 运维窗按「注册名 = 工厂名」的旧插件重注入(dm/oms/ui);
1870                            // 命名网关实例(`ctp_<broker>_<账号>`)不是工厂名,会被
1871                            // supervisor 回拒 unknown factory —— 那类实例的起停归「账户」面板。
1872                            shared.push_cmd(UiCmd::Start {
1873                                factory: r.name.clone(),
1874                                name: String::new(),
1875                            });
1876                        }
1877                        let armed = shared.pending_confirm();
1878                        let armed_here = ui
1879                            .small_button(remove_button_label(&r.name, armed.as_deref()))
1880                            .clicked()
1881                            && shared.confirm_step(&r.name) == ConfirmStep::Fired;
1882                        if armed_here {
1883                            shared.push_cmd(UiCmd::Remove {
1884                                name: r.name.clone(),
1885                            });
1886                        }
1887                    }
1888                });
1889                ui.end_row();
1890            }
1891        });
1892
1893    if let Some(name) = shared.pending_confirm() {
1894        ui.horizontal(|ui| {
1895            ui.label(
1896                egui::RichText::new(format!("待二次确认:再次点击『移除 {name}』完成"))
1897                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
1898            );
1899            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
1900                shared.confirm_cancel();
1901            }
1902        });
1903    }
1904
1905    ui.label("服务");
1906    let flat: Vec<String> = snap
1907        .services
1908        .iter()
1909        .flat_map(|(n, ids)| {
1910            if ids.is_empty() {
1911                vec![format!("{n} 服务"), "-".to_string()]
1912            } else {
1913                ids.iter()
1914                    .flat_map(|id| [format!("{n} 服务"), id.clone()])
1915                    .collect()
1916            }
1917        })
1918        .collect();
1919    if flat.is_empty() {
1920        ui.label("(无服务目录)");
1921    } else {
1922        let rows = flat.len().div_ceil(6); // 每列 2 字段 × 3 列
1923        egui::Grid::new("svc_grid")
1924            .striped(true)
1925            .num_columns(6)
1926            .show(ui, |ui| {
1927                for r in 0..rows {
1928                    for c in 0..3 {
1929                        let i = c * rows * 2 + r * 2;
1930                        if i < flat.len() {
1931                            ui.label(flat[i].clone());
1932                            ui.label(flat[i + 1].clone());
1933                        } else {
1934                            ui.label("");
1935                            ui.label("");
1936                        }
1937                    }
1938                    ui.end_row();
1939                }
1940            });
1941    }
1942
1943    ui.label("依赖");
1944    if !snap.rows.iter().any(|r| !r.deps.is_empty()) {
1945        ui.label("(全部无依赖)");
1946    }
1947    for r in &snap.rows {
1948        if !r.deps.is_empty() {
1949            ui.label(format!("{} → {}", r.name, r.deps.join(" + ")));
1950        }
1951    }
1952}
1953
1954/// 账户面板(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):
1955/// 列表上方 = 行情源一览 + 账户计数(软上限)+ 『添加账户』入口;
1956/// 列表 = **跨账户一览**(状态 / 行情源 / 收录勾选与网关实际收录 / 行操作);
1957/// 配置表单**移入独立弹窗**(`render_account_edit_window`)—— 本窗不再内嵌表单,
1958/// 旧「窗内一张共享表单」退役(消 U4 凭据串味 / U6 取消编辑不清空的界面根)。
1959fn render_accounts_window(ui: &mut egui::Ui, shared: &UiShared) {
1960    let accounts = shared.accounts();
1961    let locked = shared.accounts_locked();
1962    let md = shared.md_source();
1963    let snap = shared.trade_snap();
1964
1965    // 行内显示名(别名优先,空回退账号;重名回退派生名)。
1966    // 同一行里『编辑 X』与『移除 X』必须用同一个 X(规划 U10 口径齐),
1967    // 而寻址恒用派生名 —— 别名只是显示,不参与寻址(规划 Q3)。
1968    let row_label: std::collections::BTreeMap<String, String> = accounts
1969        .iter()
1970        .map(|a| {
1971            let d = a.display_name();
1972            let dup = accounts.iter().filter(|x| x.display_name() == d).count() > 1;
1973            (a.gateway.clone(), if dup { a.gateway.clone() } else { d })
1974        })
1975        .collect();
1976
1977    // 列表上方:行情源一览(订阅一律走它,与账户增删无关)+ 计数 + 添加入口。
1978    ui.horizontal(|ui| {
1979        ui.label(
1980            egui::RichText::new(format!(
1981                "当前行情源 = {}",
1982                if md.is_empty() {
1983                    "(未指定)".to_string()
1984                } else {
1985                    md.clone()
1986                }
1987            ))
1988            .strong(),
1989        );
1990        ui.label(format!(
1991            "账户 {}/{}",
1992            accounts.len(),
1993            crate::state::MAX_ACCOUNTS
1994        ));
1995        if shared.can_add_account() {
1996            if ui.button(BTN_ACCOUNT_ADD).clicked() {
1997                shared.begin_account_edit(None);
1998            }
1999        } else {
2000            ui.label(
2001                egui::RichText::new(format!(
2002                    "账户已达软上限 {} 个:先移除一个再添加(『添加账户』已禁用)",
2003                    crate::state::MAX_ACCOUNTS
2004                ))
2005                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2006            );
2007        }
2008    });
2009
2010    const ACCOUNT_HEADERS: [&str; 7] = [
2011        "网关(派生名)",
2012        "别名",
2013        "账号",
2014        "状态",
2015        "行情源",
2016        "收录(勾→实)",
2017        "操作",
2018    ];
2019    // 0.2.17 布局修复:账户清单不用 pinned_table。pinned_table = ScrollArea
2020    // (auto_shrink=false,吃掉整块可用高)+ 覆盖式表头带;账户窗是**自动尺寸浮动窗**,
2021    // 两者凑不出正确高度 → 表行与其后的口径句**互相压字**(快照钉 s39 实锤)。
2022    // 账户行最多个位数,普通 Grid 就够,不需要固定表头滚动面。
2023    egui::Grid::new("account_grid")
2024        .striped(true)
2025        .num_columns(7)
2026        .show(ui, |ui| {
2027            for h in &ACCOUNT_HEADERS {
2028                ui.strong(*h);
2029            }
2030            ui.end_row();
2031        if accounts.is_empty() {
2032            ui.label("(无账户:上方『添加账户』开配置弹窗)");
2033            for _ in 0..6 {
2034                ui.label("");
2035            }
2036            ui.end_row();
2037        }
2038        for a in &accounts {
2039            let is_md = a.gateway == md;
2040            let name = row_label
2041                .get(&a.gateway)
2042                .cloned()
2043                .unwrap_or_else(|| a.gateway.clone());
2044            ui.label(a.gateway.clone());
2045            // 别名(空 = 显示 "—";寻址恒用派生名,别名不参与,规划 Q3)。
2046            ui.label(if a.alias.trim().is_empty() {
2047                "—".to_string()
2048            } else {
2049                a.alias.clone()
2050            });
2051            ui.label(a.account.clone());
2052            // 状态列(0.2.19 起两段):装配态(svctl 在场位)+ **登录态**(`{gw}.status` 读数)。
2053            // 登录成败不再靠猜:ek-ctp 0.9.0 的 `{gw}.status` 是事实源(规划 P3 / 裁定 3),
2054            // 没有读数时仍写「已装配·登录态未知」,不得写成「已连接」(规划 U5 口径不变)。
2055            // 文案一律 `Extend`(不换行):账户面板是 Grid,状态列被挤窄时逐字竖排
2056            // 会把一行撑成十几行高(s39 快照实锤过)。
2057            match a.stage {
2058                crate::state::AccountStage::AssembleFailed => {
2059                    ui.add(
2060                        egui::Label::new(
2061                            egui::RichText::new(format!(
2062                                "装配失败:{}",
2063                                a.fail_note.clone().unwrap_or_else(|| "未知原因".to_string())
2064                            ))
2065                            .color(egui::Color32::from_rgb(0xC0, 0x30, 0x30)),
2066                        )
2067                        .wrap_mode(egui::TextWrapMode::Extend),
2068                    );
2069                }
2070                _ => {
2071                    let color = match a.login {
2072                        crate::state::LoginState::Online => egui::Color32::from_rgb(0x2E, 0x7D, 0x32),
2073                        crate::state::LoginState::LoginFailed
2074                        | crate::state::LoginState::FrontDown => {
2075                            egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2076                        }
2077                        crate::state::LoginState::QueryStarved => {
2078                            egui::Color32::from_rgb(0xE0, 0x88, 0x10)
2079                        }
2080                        // Unknown / Stopped:灰 —— 没有证据与主动停会话都不该着色成"出事"
2081                        _ => egui::Color32::from_rgb(0x88, 0x88, 0x88),
2082                    };
2083                    ui.add(
2084                        egui::Label::new(
2085                            egui::RichText::new(a.status_label()).color(color),
2086                        )
2087                        .wrap_mode(egui::TextWrapMode::Extend),
2088                    );
2089                }
2090            }
2091            // 行情元账号:配置阶段任选;任何账户已装配即锁定(规划 D3)。
2092            ui.horizontal(|ui| {
2093                if is_md {
2094                    ui.label(egui::RichText::new("● 行情源").strong());
2095                }
2096                let resp = ui.add_enabled_ui(!locked || is_md, |ui| {
2097                    ui.small_button(if is_md {
2098                        "已是行情源"
2099                    } else {
2100                        "设为行情源"
2101                    })
2102                });
2103                if resp.inner.clicked() {
2104                    match shared.set_md_source(&a.gateway) {
2105                        Ok(()) => shared.log_line(format!("[账户] 行情源 = {}", a.gateway)),
2106                        Err(e) => shared.log_line(format!("[账户] {e}")),
2107                    }
2108                }
2109            });
2110            // 收录:勾选(意图)→ 网关实际回带(事实),并排显示(规划 U9)。
2111            // 旧口径:`ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
2112            ui.vertical(|ui| {
2113                ui.add(
2114                    egui::Label::new(egui::RichText::new(format!(
2115                        "勾:{}",
2116                        classes_label(&a.form.product_classes())
2117                    )))
2118                    .wrap_mode(egui::TextWrapMode::Extend),
2119                );
2120                ui.add(
2121                    egui::Label::new(egui::RichText::new(format!(
2122                        "实:{}",
2123                        if a.effective_classes.is_empty() {
2124                            "—".to_string()
2125                        } else {
2126                            classes_label(&a.effective_classes)
2127                        }
2128                    )))
2129                    .wrap_mode(egui::TextWrapMode::Extend),
2130                );
2131            });
2132            // 操作:连接/重试(四态分离的『连接』入口)/ 断开(0.2.19 停会话)/ 编辑(开配置弹窗)/ 移除(守卫 + 先撤入口)。
2133            ui.horizontal(|ui| {
2134                let failed = a.stage == crate::state::AccountStage::AssembleFailed;
2135                let idle = a.stage == crate::state::AccountStage::NotAssembled;
2136                // 会话被主动停掉(『断开』)但实例还在:恢复入口仍是这一颗『连接』
2137                // (走 `UiCmd::Connect` → svctl 回「已在场」→ 插件侧改走重投凭据,规划裁定 1)。
2138                let stopped = a.login == crate::state::LoginState::Stopped;
2139                if failed || idle || stopped {
2140                    // 规划 U3 定盘:装配失败**不回滚清册行**,行留着给『重试』
2141                    // (旧口径"必须先移除才能重试"作废)。
2142                    let label = if failed {
2143                        format!("重试 {name}")
2144                    } else {
2145                        format!("连接 {name}")
2146                    };
2147                    if ui.small_button(label).clicked() {
2148                        push_connect(shared, &a.gateway, &a.form);
2149                    }
2150                }
2151                // 该账户的活跃委托数(『断开』与『移除』共用同一道守卫,见下)。
2152                let open = snap
2153                    .orders
2154                    .iter()
2155                    .filter(|o| o.gateway_name == a.gateway && o.is_active())
2156                    .count();
2157                // 『断开』(0.2.19,规划裁定 1「断开 = 停会话,移除 = 停实例」):
2158                // 只对**已装配、会话还活着、且没有活跃委托**的行开。
2159                // - 未装配没有会话可停(规划 Q21:口不存在时不留死钮;现在口已实现,钮才允许出现);
2160                // - 有活跃委托时不给断开 —— 会话一停就撤不了单、也不再回报,与『移除』
2161                //   要挡的是同一个观测黑洞(规划 §5.B),出口同样是旁边那句『先撤 N 笔』。
2162                // `&&` 短路即"条件不成立就不画这颗钮",不是画出来再禁用。
2163                if a.stage == crate::state::AccountStage::Assembled
2164                    && a.login != crate::state::LoginState::Stopped
2165                    && open == 0
2166                    && ui.small_button(format!("断开 {name}")).clicked()
2167                {
2168                    shared.push_cmd(UiCmd::Disconnect {
2169                        gateway: a.gateway.clone(),
2170                    });
2171                    shared.log_line(format!(
2172                        "[账户] 断开 {gateway}:已入列(停会话;实例与订阅保留,『连接』可恢复)",
2173                        gateway = a.gateway
2174                    ));
2175                }
2176                if ui.small_button(format!("编辑 {name}")).clicked() {
2177                    // 编辑 = 开配置弹窗(草稿 = 该账户自己的留档)。
2178                    // 旧的 `select_row("account_grid", …)` 行选记槽已无读者(内嵌表单时代遗留)→ 撤除。
2179                    shared.begin_account_edit(Some(&a.gateway));
2180                }
2181                let has_pos = snap
2182                    .positions
2183                    .iter()
2184                    .any(|p| p.gateway_name == a.gateway && (p.volume + p.yd_volume) > 0.0);
2185                if open > 0 {
2186                    // 守卫硬挡:柜台还在跑单而没人能撤 = 观测黑洞(规划 §5.B)。
2187                    ui.label(
2188                        egui::RichText::new(format!("有 {open} 笔活跃委托:先撤单才能移除"))
2189                            .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2190                    );
2191                    // 规划 U10:守卫不能只把用户堵在门口,要给出口 ——
2192                    // 『先撤 N 笔』按该账户的活跃委托逐笔入列(与「全撤」同一条 oms 通道,
2193                    // 走每笔单自己的网关,撞撤单计数守卫时显形 limit:cancel-guard)。
2194                    if ui.small_button(format!("先撤 {open} 笔")).clicked() {
2195                        for o in snap
2196                            .orders
2197                            .iter()
2198                            .filter(|o| o.gateway_name == a.gateway && o.is_active())
2199                        {
2200                            shared.push_cmd(UiCmd::CancelOrder {
2201                                orderid: o.orderid.clone(),
2202                                gateway: a.gateway.clone(),
2203                            });
2204                        }
2205                        shared.log_line(format!(
2206                            "[账户] 先撤 {}:{} 笔活跃委托已入列(撤完才可移除)",
2207                            a.gateway, open
2208                        ));
2209                    }
2210                } else {
2211                    // 二次确认的记槽按**派生名**(寻址主键),按钮文案用显示名 ——
2212                    // 两者必须在这里对上,否则首点后按钮永远翻不到『确认移除』(Commit B 自查抓到的坑)。
2213                    let armed = shared.pending_confirm();
2214                    let armed_here = armed.as_deref() == Some(a.gateway.as_str());
2215                    let fired = ui
2216                        .small_button(remove_button_label(
2217                            &name,
2218                            if armed_here { Some(name.as_str()) } else { None },
2219                        ))
2220                        .clicked()
2221                        && shared.confirm_step(&a.gateway) == ConfirmStep::Fired;
2222                    if fired {
2223                        shared.push_cmd(UiCmd::Remove {
2224                            name: a.gateway.clone(),
2225                        });
2226                        shared.remove_account(&a.gateway);
2227                        // 弹窗是非 modal:若这行正在被编辑,摘除必须连带关弹窗放弃,
2228                        // 否则弹窗会静默退化成「添加模式」,点『保存』会拿旧凭据建新账户。
2229                        shared.end_account_edit_if_editing(&a.gateway);
2230                        if has_pos {
2231                            shared.log_line(format!(
2232                                "[账户] 移除 {}:有持仓 —— 柜台侧不受影响,重加该账户后由轮转查询重建视图",
2233                                a.gateway
2234                            ));
2235                        }
2236                    }
2237                }
2238            });
2239            ui.end_row();
2240        }
2241    });
2242
2243    if let Some(gw) = shared.pending_confirm() {
2244        // 提示句与行内钮同一显示名(行里写『移除 220380』,提示就不能写『移除 ctp_9999_220380』)。
2245        let shown = row_label.get(&gw).cloned().unwrap_or(gw.clone());
2246        ui.horizontal(|ui| {
2247            ui.label(
2248                egui::RichText::new(format!("待二次确认:再次点击『移除 {shown}』完成"))
2249                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2250            );
2251            if ui.small_button(BTN_CANCEL_CONFIRM).clicked() {
2252                shared.confirm_cancel();
2253            }
2254        });
2255    }
2256
2257    ui.label(if locked {
2258        "行情源已锁定(有账户已装配):换源需先移除该账户 → 改配置 → 重连(规划 D3)"
2259    } else {
2260        "行情源:配置阶段任选;非源账户的 md 前置一律留空(单源纪律 §5.C)"
2261    });
2262    // 状态口径统一说明(面板底部一句,不逐行重复 —— 逐行会把状态列撑成竖排):
2263    ui.label(
2264        egui::RichText::new(format!(
2265            "状态列 = 实例装配 + 网关状态口 {{gw}}.status(每 {} 秒轮询);『断开』= 停会话(实例与订阅保留),『移除』= 停实例",
2266            crate::state::STATUS_POLL_TICKS
2267        ))
2268        .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2269    );
2270    // F4 口径显形(0.2.20):多账户下「谁查谁过滤」此前只能靠猜 —— 实连 S14b 里
2271    // 账户 B 把勾选改成「期货+期权」,公用合约表仍是 852 条、期权 0 条,用户没有任何
2272    // 办法判断是自己没生效、还是网关没查到。这里把口径写死成一句(不靠界面猜):
2273    // 勾选只管**这个网关自己**收录并上抛的部分,公用表是在场各网关的并集。
2274    ui.label(
2275        egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE)
2276            .color(egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)),
2277    );
2278    // 状态细节(0.2.19):凡是有话可说的行都给一句(带次数、带秒数、带原因)。
2279    // 为什么不在状态列里带:Grid 列宽实测会被长文案挤成竖排(s39 实锤)。
2280    // 为什么不只给告警行:「状态口问不到」与「纯行情账户」也是事实 ——
2281    // 灰色徽标不给原因,用户就只能猜「是没问到还是没登录」。
2282    // 只给已装配的行:会话读数属于那个实例,行都「未装配」了还印「掉线 2 次」
2283    // 就是在替一个不存在的会话说话(状态侧由 `sync_accounts_connected` 一并作废,
2284    // 这里是不对称时刻——快照还没刷——的第二道闸)。
2285    // 健康行没有细节(`count_note(…, 0) = None`),所以这一段不会随账户数无谓变长。
2286    for a in accounts
2287        .iter()
2288        .filter(|a| a.stage == crate::state::AccountStage::Assembled && a.status_note.is_some())
2289    {
2290        let note = a.status_note.clone().unwrap_or_default();
2291        let alarm = a.login.is_alarm() || a.login == crate::state::LoginState::QueryStarved;
2292        let color = if alarm {
2293            egui::Color32::from_rgb(0xC0, 0x30, 0x30)
2294        } else {
2295            egui::Color32::from_rgb(0x9A, 0xA8, 0xC0)
2296        };
2297        ui.label(egui::RichText::new(format!("状态细节 {}:{}", a.gateway, note)).color(color));
2298    }
2299    // 元账号 md 前置为空 = 永远收不到 tick(添加时被单源纪律清空,须编辑页补填)。
2300    if let Some(src) = accounts.iter().find(|a| a.gateway == md) {
2301        if src.form.md_addr.trim().is_empty() {
2302            ui.label(
2303                egui::RichText::new(format!(
2304                    "元账号 {} 的 md 前置为空:点『编辑 {}』补填 md 前置,否则行情带不会出数",
2305                    src.gateway,
2306                    row_label
2307                        .get(&src.gateway)
2308                        .cloned()
2309                        .unwrap_or_else(|| src.account.clone())
2310                ))
2311                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2312            );
2313        }
2314    }
2315}
2316
2317/// 账户配置弹窗(0.2.18 Commit B,规划裁定「一张列表 + 配置弹窗」):『添加账户』与
2318/// 『编辑 <账户>』开**同一个弹窗的两种模式**;非 modal(不阻塞主窗与行情观察)。
2319/// 账号/前置全部界面手工输入,插件不读文件;密码位掩码;**无订阅字段** ——
2320/// 用户裁定:订阅走下单面板代码回车或查询合约窗勾选。
2321/// 关闭(『取消』或窗 ×)= **放弃未保存**(规划 Q24);被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
2322fn render_account_edit_window(ui: &mut egui::Ui, shared: &UiShared) {
2323    let editing: Option<crate::state::AccountRow> =
2324        shared.editing_account().and_then(|g| shared.account(&g));
2325    let editing_on = editing.is_some();
2326    let md = shared.md_source();
2327    let locked = shared.accounts_locked();
2328    let mut form: crate::state::CtpForm = shared.ctp_form();
2329    let mut alias = shared.edit_alias();
2330    // 被拦原因上屏(形制校验 / 清册守卫 / 装配被拒):弹窗不关(规划 U3 / Q26)。
2331    if let Some(e) = shared.edit_error() {
2332        ui.label(egui::RichText::new(format!("拦下:{e}")).color(egui::Color32::RED));
2333    }
2334    // 0.2.17 布局口径:表单用 horizontal 行不用 Grid —— Grid 列宽在测量期被
2335    // 「窗宽 / 列数」夹死(实测每格 ≈120px),`desired_width` 永不生效,
2336    // 一根真实前置 `tcp://182.254.242.81:30001`(30 字符)只显示出 "tcp:/"。
2337    // 身份行:别名(仅显示用,不参与寻址,规划 Q3)+ broker + td 前置 + td 用户。
2338    // 编辑模式 broker / td 用户**只读**(规划 Q25:它们决定派生注册名,就地改 = 孤儿实例 + 账本错配)。
2339    ui.horizontal(|ui| {
2340        ui.label("别名");
2341        if ui
2342            .add(
2343                egui::TextEdit::singleline(&mut alias)
2344                    .hint_text("显示用,可空")
2345                    .desired_width(120.0),
2346            )
2347            .changed()
2348        {
2349            shared.set_edit_alias(alias.clone());
2350        }
2351        ui.label("broker");
2352        ui.add_enabled_ui(!editing_on, |ui| {
2353            ctp_edit(ui, shared, &mut form.broker_id, "9999", CtpField::Broker);
2354        });
2355        ui.label("td 前置");
2356        ctp_edit(
2357            ui,
2358            shared,
2359            &mut form.td_addr,
2360            "tcp://host:40001",
2361            CtpField::TdAddr,
2362        );
2363        ui.label("td 用户");
2364        ui.add_enabled_ui(!editing_on, |ui| {
2365            ctp_edit(ui, shared, &mut form.td_user, "用户号", CtpField::TdUser);
2366        });
2367    });
2368    if let Some(r) = &editing {
2369        // 只读口径说明(规划 Q25):派生注册名由 broker + 账号决定,就地改会留下
2370        // 跑着旧名的孤儿实例(svctl 侧无改名口),账本里的归属也会错配。
2371        ui.label(
2372            egui::RichText::new(format!(
2373                "派生注册名 = {}(由 broker + 账号决定):要改这两格请『移除后重加』",
2374                r.gateway
2375            ))
2376            .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2377        );
2378    }
2379    ui.horizontal(|ui| {
2380        ui.label("td 密码");
2381        ctp_edit(
2382            ui,
2383            shared,
2384            &mut form.td_password,
2385            "密码",
2386            CtpField::TdPassword,
2387        );
2388        ui.label("AppID");
2389        ctp_edit(ui, shared, &mut form.td_app_id, "app", CtpField::AppId);
2390        ui.label("AuthCode");
2391        ctp_edit(
2392            ui,
2393            shared,
2394            &mut form.td_auth_code,
2395            "授权码",
2396            CtpField::AuthCode,
2397        );
2398    });
2399    // 单源纪律(规划 Q12):编辑模式下「非行情源账户 + 行情源已锁定」→ md 三格置灰。
2400    // 它当不了源,填了也会在装配侧被清空;要换源须先移除在场账户(规划 D3)。
2401    let md_editable = match &editing {
2402        None => true,
2403        Some(r) => !locked || r.gateway == md,
2404    };
2405    ui.horizontal(|ui| {
2406        ui.label("md 前置");
2407        ui.add_enabled_ui(md_editable, |ui| {
2408            ctp_edit(
2409                ui,
2410                shared,
2411                &mut form.md_addr,
2412                "tcp://host:40011",
2413                CtpField::MdAddr,
2414            );
2415            ui.label("md 用户");
2416            ctp_edit(
2417                ui,
2418                shared,
2419                &mut form.md_user,
2420                "空=td 回落",
2421                CtpField::MdUser,
2422            );
2423            ui.label("md 密码");
2424            ctp_edit(
2425                ui,
2426                shared,
2427                &mut form.md_password,
2428                "空=td 回落",
2429                CtpField::MdPassword,
2430            );
2431        });
2432        if !md_editable {
2433            ui.label(
2434                egui::RichText::new("非行情源账户:md 位留空(换源需先移除在场账户)")
2435                    .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2436            );
2437        }
2438    });
2439    // 收录勾选(ek-ctp 0.6.0 D-PC-FILTER):网关收录哪些产品类。勾选变更立刻回写共享表单
2440    // —— 与 ctp_edit 同一口径,否则下一帧从 shared 重读就把这次点击丢了。
2441    ui.horizontal(|ui| {
2442        ui.label("收录勾选:");
2443        let fut = ui.checkbox(&mut form.accept_futures, "期货('1')").changed();
2444        let opt = ui.checkbox(&mut form.accept_options, "期权('2')").changed();
2445        if fut || opt {
2446            shared.set_ctp_form(form.clone());
2447        }
2448        ui.label("都不勾 = 回落默认期货;未勾选的合约收到即丢(其持仓/成交/tick 一并不上账)");
2449    });
2450    // F4 口径(0.2.20,与账户面板底部同一句 —— 共用 `TXT_PRODUCT_CLASS_SCOPE` 常量)。
2451    // 公用合约表是在场各网关的并集:从账户改了勾选、行情源账户没改,表就不会多一行期权。
2452    ui.label(egui::RichText::new(TXT_PRODUCT_CLASS_SCOPE).weak());
2453    // 只读回带区(规划 U9):勾选是**意图**,网关回带才是**事实** ——
2454    // 旧口径 `ConfigureReply.product_classes` 既不回填也不显示,勾选是否生效无从判断。
2455    if let Some(r) = &editing {
2456        ui.horizontal(|ui| {
2457            ui.label("网关实际收录(只读回带):");
2458            ui.label(if r.effective_classes.is_empty() {
2459                "—(尚未装配或无回带)".to_string()
2460            } else {
2461                classes_label(&r.effective_classes)
2462            });
2463            ui.label("装配/登录状态(只读):");
2464            ui.label(format!(
2465                "{}{}",
2466                r.status_label(),
2467                r.fail_note
2468                    .as_ref()
2469                    .map(|n| format!(" —— {n}"))
2470                    .unwrap_or_default()
2471            ));
2472        });
2473    }
2474    // 操作行(规划 §3.3 四态):『保存』只写清册;『保存并连接』= 保存 + 入列一条 Connect 令;
2475    // 『取消』= 放弃未保存(与窗 × 同口径,规划 Q24)。被拦 → 弹窗不关 + 原因上屏(规划 Q26)。
2476    if shared.account_edit_dirty() {
2477        ui.label(
2478            egui::RichText::new("有未保存修改:『保存』写入清册,『取消』或关闭 = 放弃")
2479                .color(egui::Color32::from_rgb(0xE0, 0x88, 0x10)),
2480        );
2481    }
2482    ui.horizontal(|ui| {
2483        if ui.button("保存").clicked() {
2484            match save_account_draft(shared, &form, &alias, false) {
2485                Ok(()) => shared.end_account_edit(),
2486                Err(e) => {
2487                    shared.log_line(format!("[账户] 保存被拦:{e}"));
2488                    shared.set_edit_error(Some(e));
2489                }
2490            }
2491        }
2492        if ui.button("保存并连接").clicked() {
2493            match save_account_draft(shared, &form, &alias, true) {
2494                Ok(()) => shared.end_account_edit(),
2495                Err(e) => {
2496                    shared.log_line(format!("[账户] 保存并连接被拦:{e}"));
2497                    shared.set_edit_error(Some(e));
2498                }
2499            }
2500        }
2501        if ui.button("取消").clicked() {
2502            shared.end_account_edit();
2503        }
2504        ui.label("订阅不在本窗定死:查询合约窗勾选或下单面板代码回车(一律路由到行情源)");
2505    });
2506}
2507
2508/// 账户清册里的派生名集合(账本标注用,每表算一次,不在每格里克隆清册)。
2509fn account_gateways(shared: &UiShared) -> std::collections::BTreeSet<String> {
2510    shared
2511        .accounts()
2512        .iter()
2513        .map(|a| a.gateway.clone())
2514        .collect()
2515}
2516
2517/// 账本行的网关标注(0.2.18 Commit B,规划 U8):该网关已不在账户清册 = **已摘除账户的残留行**。
2518///
2519/// 摘除不清账本(柜台侧不受影响,重加该账户后由轮转查询重建视图 —— F5 实连复测:
2520/// 摘除后账本 81/29/44/2 原封不动),但界面必须说清这一行属于谁:旧口径零标注,
2521/// 用户会把已摘除账户的委托当成在场账户的。
2522/// 口径边界:**清册为空 = 不标注**(没有账户配置面时谈不上「摘除」,纯账本巡回钉 s27 即此形);
2523/// 资金格不标注(那里的网关位是账户来源标签,`ctp` 这类历史字面量不在清册口径内 —— 刻意划界)。
2524fn ledger_gw_label(gateway: &str, known: &std::collections::BTreeSet<String>) -> String {
2525    if known.is_empty() || gateway.is_empty() || known.contains(gateway) {
2526        gateway.to_string()
2527    } else {
2528        format!("{gateway}(已摘除)")
2529    }
2530}
2531
2532/// 产品类短标签(`"1"` → 期货,`"2"` → 期权):勾选意图与网关回带事实并排显示用。
2533fn classes_label(classes: &[String]) -> String {
2534    if classes.is_empty() {
2535        return "—".to_string();
2536    }
2537    classes
2538        .iter()
2539        .map(|c| match c.as_str() {
2540            "1" => "期货",
2541            "2" => "期权",
2542            other => other,
2543        })
2544        .collect::<Vec<_>>()
2545        .join("+")
2546}
2547
2548/// 『连接』入口的唯一出令处(四态分离:一条令做完 `svctl.start` + `{gw}.configure`)。
2549///
2550/// 入列同时就地置「装配中」:插件出队时也会置(幂等),但**入列到出队之间**若不置,
2551/// 行上的『连接/重试』钮还亮着 = 可重复入列(规划 Q7 防重入的界面侧一半)。
2552fn push_connect(shared: &UiShared, gateway: &str, form: &crate::state::CtpForm) {
2553    shared.push_cmd(UiCmd::Connect {
2554        gateway: gateway.to_string(),
2555        form: form.clone(),
2556    });
2557    shared.note_connect_sent(gateway);
2558    shared.log_line(format!(
2559        "[账户] {gateway} 连接已入列(svctl.start + {}.configure)",
2560        gateway
2561    ));
2562}
2563
2564/// 配置弹窗『保存』/『保存并连接』的共同路径:**校验 → 写清册 →(可选)入列一条 Connect 令**。
2565///
2566/// - 编辑模式:覆盖该账户留档 + 别名(broker/账号只读 → 派生名不变,规划 Q25)。
2567/// - 添加模式:派生建行(别名随行入册,唯一性在 `add_account` 守卫里)+ 单源定源(规划 §5.C / D3)。
2568///
2569/// 被拦返回原因字符串:调用方**不关弹窗**,把原因写进 `edit_error` 显形(规划 Q26)。
2570fn save_account_draft(
2571    shared: &UiShared,
2572    form: &crate::state::CtpForm,
2573    alias: &str,
2574    connect: bool,
2575) -> Result<(), String> {
2576    form.validate()?;
2577    if let Some(gw) = shared.editing_account() {
2578        // 编辑目标必须还在清册(弹窗非 modal:期间该行可能被摘除)。
2579        if shared.account(&gw).is_none() {
2580            return Err(format!(
2581                "编辑目标 {gw} 已不在清册(该行被摘除):关闭弹窗后重新『添加』"
2582            ));
2583        }
2584        shared.update_account_form(&gw, form.clone());
2585        if !shared.set_account_alias(&gw, alias) {
2586            return Err(format!(
2587                "别名 {alias:?} 不可用(已被其他账户占用;别名须唯一)"
2588            ));
2589        }
2590        shared.log_line(format!(
2591            "[账户] 保存 {gw}(留档已更新;未重连:点行内『连接/重试』才下发到网关)"
2592        ));
2593        if connect {
2594            push_connect(shared, &gw, form);
2595        }
2596        return Ok(());
2597    }
2598    let mut row = crate::state::AccountRow::derive(form)?;
2599    row.alias = alias.trim().to_string();
2600    let gateway = row.gateway.clone();
2601    let account = row.account.clone();
2602    shared.add_account(row.clone())?;
2603    // 单源纪律在「保存」这一刻定源(规划 §5.C / D3):无源且无账户在场 → 新账户就是元账号
2604    // (md 前置保留 —— 清掉它等于这个账户自己也永远收不到 tick);
2605    // 已有源或已有账户在场 → 按纯 TD 装配(md 位清空,**留档与下发同一份**,消 U4)。
2606    let md = shared.md_source();
2607    let mut f = row.form.clone();
2608    if md.is_empty() && !shared.accounts_locked() {
2609        match shared.set_md_source(&gateway) {
2610            Ok(()) => {
2611                if f.md_addr.trim().is_empty() {
2612                    shared.log_line(format!(
2613                        "[账户] {gateway} 已设为行情源但 md 前置为空:点『编辑 {account}』补填 md 前置才出行情"
2614                    ));
2615                } else {
2616                    shared.log_line(format!("[账户] {gateway} = 行情源(首个账户自动指定,§5.C)"));
2617                }
2618            }
2619            Err(e) => {
2620                shared.log_line(format!("[账户] {e}"));
2621                f.md_addr.clear();
2622                f.md_user.clear();
2623                f.md_password.clear();
2624            }
2625        }
2626    } else {
2627        f.md_addr.clear();
2628        f.md_user.clear();
2629        f.md_password.clear();
2630        if md.is_empty() {
2631            shared.log_line("[账户] 已有账户在场:新账户按纯 TD 装配,换源需先移除在场账户");
2632        }
2633    }
2634    shared.update_account_form(&gateway, f.clone());
2635    // **四态分离:『保存』到此为止** —— 不 start、不 configure(规划裁定)。
2636    shared.log_line(format!(
2637        "[账户] 添加 {gateway}(未装配:点行内『连接 {account}』上线)"
2638    ));
2639    if connect {
2640        push_connect(shared, &gateway, &f);
2641    }
2642    Ok(())
2643}
2644
2645/// 查询合约窗(Python ContractManager 等价):查询串(代码/交易所/名称模糊,
2646/// 留空 = 全部)+ 勾选表 + 订阅钮(勾选集 → UiCmd::CtpSubscribe → ask
2647/// ctp.subscribe)。合约数据 = `eContract.` 自动回流,窗不催查询。
2648fn render_contracts_window(ui: &mut egui::Ui, shared: &UiShared) {
2649    let mut q = shared.contract_query();
2650    ui.horizontal(|ui| {
2651        ui.label("代码或交易所(留空 = 全部):");
2652        if ui
2653            .add(
2654                egui::TextEdit::singleline(&mut q)
2655                    .hint_text("cu 或 SHFE")
2656                    .desired_width(200.0),
2657            )
2658            .changed()
2659        {
2660            shared.set_contract_query(q.clone());
2661        }
2662    });
2663    let rows = shared.contracts(&q);
2664    // 表头单源(0.2.6 审计修:空态分支曾误写另一套表头「代码/…/最后价」,
2665    // 与非空分支「选(代码)/…/最小变动」不一致——列语义两处必须同源)。
2666    const CONTRACT_HEADERS: [&str; 5] = ["选(代码)", "交易所", "名称", "乘数", "最小变动"];
2667    if rows.is_empty() {
2668        // 空态提示移入表格 body(0.2.8,BD-UI-35,同未成交口径);动作行无意义可省。
2669        pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2670            ui.label("(无合约:先连接 CTP,合约随 eContract. 自动回流)");
2671        });
2672        return;
2673    }
2674    let selected = shared.selected_contracts();
2675    // 动作行在前、表格吃满剩余窗高(BD-UI-33,用户实看指出):旧
2676    // max_height(260) 是内嵌弹窗时代残留——OS 窗里表格定高 260px,
2677    // 放大窗口表格不跟着长,放大就没了意义。现在放大窗口 = 表格变高,
2678    // 行溢出才出滚动条。
2679    ui.horizontal(|ui| {
2680        ui.label(format!("已选 {} 项 / 共 {} 项", selected.len(), rows.len()));
2681        if ui.button(BTN_SUBSCRIBE_SELECTED).clicked() {
2682            if selected.is_empty() {
2683                shared.log_line("[查询合约] 未勾选任何合约,订阅未发");
2684            } else {
2685                // en_symbol 勾选集 → CTP 裸代码订阅(ask {gw}.subscribe 收裸代码)
2686                let symbols: Vec<String> = selected
2687                    .iter()
2688                    .map(|k| {
2689                        k.split_once('.')
2690                            .map(|(_, s)| s.to_string())
2691                            .unwrap_or_else(|| k.clone())
2692                    })
2693                    .collect();
2694                match subscribe_target(shared) {
2695                    Ok(gw) => {
2696                        shared.push_cmd(UiCmd::CtpSubscribe {
2697                            gateway: gw.clone(),
2698                            symbols,
2699                        });
2700                        shared.log_line(format!(
2701                            "[ops] 查询合约窗订阅 {} 项已入列 → {gw}(等 eTimer 请辞 → {}.subscribe)",
2702                            selected.len(),
2703                            gw
2704                        ));
2705                    }
2706                    Err(e) => shared.log_line(format!("[订阅] {e}")),
2707                }
2708            }
2709        }
2710    });
2711    pinned_table(ui, "contract_grid", 5, &CONTRACT_HEADERS, |ui| {
2712        for c in &rows {
2713            let key = format!("{}.{}", c.exchange.as_str(), c.symbol);
2714            let mut on = selected.contains(&key);
2715            // 勾选钮标签 = en_symbol(唯一可定位;订阅钮按勾选集发)。
2716            // BD-UI-31 扩展:勾选 = 订阅选择 + 行选中;其余格单击 =
2717            // 行选中(动作归行,不归某一列)。
2718            let row_sel = shared.row_selected("contracts", &key);
2719            let mut cells = Vec::with_capacity(5);
2720            let ck = ui.checkbox(&mut on, key.clone());
2721            if ck.changed() {
2722                shared.toggle_contract_selection(&key);
2723                shared.select_row("contracts", &key);
2724            }
2725            cells.push(ck.rect);
2726            macro_rules! cell {
2727                ($base:expr, $col:expr) => {{
2728                    let base = $base;
2729                    let r = click_cell(ui, base.rect, ("crt", &key, $col));
2730                    if r.clicked() {
2731                        shared.select_row("contracts", &key);
2732                    }
2733                    cells.push(base.rect);
2734                }};
2735            }
2736            cell!(ui.label(c.exchange.as_str().to_string()), 1);
2737            cell!(ui.label(c.name.clone()), 2);
2738            cell!(ui.label(format!("{:.0}", c.size)), 3);
2739            cell!(ui.label(format!("{:.4}", c.price_tick)), 4);
2740            paint_row_bg(ui, &cells, row_sel);
2741            ui.end_row();
2742        }
2743    });
2744}
2745
2746/// CTP 表单字段枚举(ctp_edit 回写定位;订阅标的字段已撤出网关窗)。
2747enum CtpField {
2748    Broker,
2749    TdAddr,
2750    TdUser,
2751    TdPassword,
2752    AppId,
2753    AuthCode,
2754    MdAddr,
2755    MdUser,
2756    MdPassword,
2757}
2758
2759/// 单行 TextEdit + changed 回写 CTP 表单(密码位掩码)。
2760fn ctp_edit(ui: &mut egui::Ui, shared: &UiShared, val: &mut String, hint: &str, field: CtpField) {
2761    let is_secret = matches!(
2762        field,
2763        CtpField::TdPassword | CtpField::MdPassword | CtpField::AuthCode
2764    );
2765    let mut w = egui::TextEdit::singleline(val)
2766        .hint_text(hint)
2767        // 0.2.17:输入位加宽(旧 110 装不下一根 CTP 前置 `tcp://182.254.242.81:30001`
2768        // = 30 字符,用户看不见自己填的前置,也无法核对端口;快照钉 s39 前后对照)。
2769        .desired_width(210.0);
2770    if is_secret {
2771        w = w.password(true);
2772    }
2773    if ui.add(w).changed() {
2774        let mut f = shared.ctp_form();
2775        match field {
2776            CtpField::Broker => f.broker_id = val.clone(),
2777            CtpField::TdAddr => f.td_addr = val.clone(),
2778            CtpField::TdUser => f.td_user = val.clone(),
2779            CtpField::TdPassword => f.td_password = val.clone(),
2780            CtpField::AppId => f.td_app_id = val.clone(),
2781            CtpField::AuthCode => f.td_auth_code = val.clone(),
2782            CtpField::MdAddr => f.md_addr = val.clone(),
2783            CtpField::MdUser => f.md_user = val.clone(),
2784            CtpField::MdPassword => f.md_password = val.clone(),
2785        }
2786        shared.set_ctp_form(f);
2787    }
2788}
2789
2790/// K线窗内容(dm bar 合成序列;格定点 = 该合约格;无格占位)。
2791///
2792/// 图实例每帧重建(BD-UI-20 口径:数据在 store 不丢,交互状态不跨帧)。
2793fn render_kline_cell(ui: &mut egui::Ui, cell: Option<&CellView>) {
2794    let Some(c) = cell else {
2795        ui.label("(K线无格:等 dm 合成)");
2796        return;
2797    };
2798    if c.bars.is_empty() {
2799        ui.label(format!("(K线:{}@{} 批空)", c.symbol, c.interval));
2800        return;
2801    }
2802    // 柱时间按北京钟面给图:egui_charts 0.2 的 Bar 只吃 DateTime<Utc> 且按
2803    // UTC 钟面画刻度 —— 用「换到 +08 再标回 UTC」的重标定,让横轴刻度显示
2804    // 北京钟面(数据面存 UTC 纳秒;重标定只挪刻度,不挪序)。
2805    let mut data = egui_charts::model::bar::BarData::new();
2806    for b in &c.bars {
2807        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(b.datetime)
2808            .with_timezone(&beijing_tz())
2809            .with_timezone(&chrono::Utc);
2810        data.push(egui_charts::model::bar::Bar::new(
2811            dt,
2812            b.open_price,
2813            b.high_price,
2814            b.low_price,
2815            b.close_price,
2816            b.volume,
2817        ));
2818    }
2819    ui.label(format!(
2820        "K线 {}@{}s({} 根)",
2821        c.symbol,
2822        c.interval,
2823        c.bars.len()
2824    ));
2825    let mut chart = egui_charts::widget::Chart::new(data).visible_bars(60);
2826    chart.set_symbol(&c.symbol);
2827    chart.set_timeframe_label(&format!("{}s", c.interval));
2828    chart.enable_tracking_mode();
2829    let _ = chart.show(ui);
2830}
2831
2832#[cfg(test)]
2833mod tests {
2834    use super::*;
2835
2836    // 表内时间列北京钟面钉(0.2.13):数据面存 UTC 纳秒,展示换回 +08 ——
2837    // 北京 09:30:01 入账后是 UTC 01:30:01,fmt_dt 必须显示 09:30:01,
2838    // 不是 01:30:01(旧实现的 UTC 直显,交易员看差 8 小时)。
2839    #[test]
2840    fn fmt_dt_shows_beijing_wall_clock() {
2841        let expect = chrono::DateTime::<chrono::Utc>::from_timestamp_nanos(
2842            ek_data::time::civil_to_nanos(ek_data::time::Civil {
2843                year: 2026,
2844                month: 4,
2845                day: 16,
2846                hour: 1,
2847                minute: 30,
2848                second: 1,
2849                nanos: 0,
2850            }),
2851        );
2852        // 真值自洽:UTC 01:30:01 ↔ 北京 09:30:01(双向换算回原钟面)
2853        let beijing = expect.with_timezone(&beijing_tz());
2854        assert_eq!(beijing.format("%H:%M:%S").to_string(), "09:30:01");
2855        // 再来一个跨日钉:北京 00:00:30 = UTC 前日 16:00:30(同 UTC 日,
2856        // 不同北京日 —— 旧实现会把这根显示成 16:00:30)
2857        let ns = ek_data::time::civil_to_nanos(ek_data::time::Civil {
2858            year: 2026,
2859            month: 4,
2860            day: 15,
2861            hour: 16,
2862            minute: 0,
2863            second: 30,
2864            nanos: 0,
2865        });
2866        assert_eq!(fmt_dt(Some(ns)), "00:00:30");
2867        // None → 占位
2868        assert_eq!(fmt_dt(None), "-");
2869    }
2870}